> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentcard.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Complete a purchase on a Shopify store from an agent browser

> Learn how an agent buys from a Shopify store and pays with the user's Vault card from Kernel, Browserbase, Browser Use, Anchor or a browser you run yourself, from the product page to the merchant's confirmation number.

This guide runs one real purchase end to end, once per agent browser the Vault integrates with: an agent puts a \$0.99 PDF from a Shopify store in the cart, reaches the checkout, and pays with a card the user stored in the Vault. The user approves on their phone with Face ID, Shopify confirms the order, and the browser is ended. The shopping leg, the attach call, the placeholder card and the merchant result reader are the same in every browser; only how the browser starts, how you watch it and how it ends differ, and those parts are in tabs. Pick your browser in each tab group; the prose between the groups applies to all of them.

Every screenshot and every line of output below comes from a run that bought the PDF: Kernel on 2026-10-04, Browserbase, Anchor and a local Chrome on 2026-10-05, and Browser Use on 2026-10-06. Each tab names its order. The integration pages ([Kernel](/vault/integrations/agent-browsers/kernel), [Browserbase](/vault/integrations/agent-browsers/browserbase), [Browser Use](/vault/integrations/agent-browsers/browser-use), [Anchor](/vault/integrations/agent-browsers/anchor), [Your own browser](/vault/integrations/agent-browsers/your-own)) cover how each browser connects to the Vault and the attach snippet. This guide does not repeat them. It shows everything around that snippet: the shopping leg a Shopify store needs, what to pass when you attach, what the SDK reports while the user decides, how to read Shopify's confirmation number, and how to leave nothing running.

What you end up with: one script, `BROWSER=<name> npm run buy`, that prints `completed  order #XTLQVAF0Y` and a browser you can watch while it happens.

## Before you start

<Note>
  A real purchase needs production credentials. The sandbox recognises the card request and pauses it, but no approval link is sent and nothing is charged. Run the sandbox first to see the pause, then switch the credentials.
</Note>

* `@agent-cards/sdk` and `playwright-core` installed, plus the SDK of the browser you pick: `@onkernel/sdk`, `@browserbasehq/sdk`, `browser-use-sdk` or `anchorbrowser`, and that provider's API key. Your own browser needs a Chromium to launch instead. `@agent-cards/sdk` is the SDK's current name; `@agent-cards/checkout` still installs and re-exports it.
* An Agentcard organization with production credentials (`client_id` and `client_secret`), and a user of that organization with a card in the Vault. [Adding a card](/vault/adding-a-card) gets you both; the user's `user_id` is what you pass to the SDK.
* A Shopify store with guest checkout and something cheap. The runs below use [The Good and the Beautiful](https://www.goodandbeautiful.com), a homeschool publisher, and its \$0.99 High School Biology Answer Key (PDF): a digital product, so nothing ships and the checkout asks for an email and a billing address only.
* A way to reach the user, and their phone within reach. The approval link is theirs to open, and it expires after 15 minutes.

## Tools

<CardGroup cols={3}>
  <Card title="Agent browsers" href="/vault/integrations/agent-browsers/kernel" img="https://mintcdn.com/agentcard/K-bylD7afogWRSuH/images/logos/playwright.svg?fit=max&auto=format&n=K-bylD7afogWRSuH&q=85&s=9b4377712e33e7fe68ac15d9d29f24c2" width="64" height="64" data-path="images/logos/playwright.svg" />

  <Card title="Agentcard Vault" href="/vault/quickstart" img="https://mintcdn.com/agentcard/h4dHYVJUFjVn9a7-/images/logos/agentcard.png?fit=max&auto=format&n=h4dHYVJUFjVn9a7-&q=85&s=65d1dac5a31a8025bfb126178b459052" width="512" height="512" data-path="images/logos/agentcard.png" />

  <Card title="Shopify checkout" href="https://www.shopify.com" img="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/logos/shopify-bag.svg?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=52f6ebb2c31ddb562466ce3cfa3521de" width="64" height="64" data-path="images/logos/shopify-bag.svg" />
</CardGroup>

## How it works

| Moment | Who acts | What happens |
| - | - | - |
| Start | Your code | Creates or launches the browser and connects Playwright to it. The only step that differs per browser. |
| Shop | Your agent, in the browser | Product page, cart, checkout. Shopify's checkout is one page: contact, payment method, billing address, card. |
| Attach | Your code | `attachToPlaywright` arms the page before the card form is touched. The SDK syncs the processors it recognises; Shopify's card session request is one of them. |
| Pay | Your agent | Types a placeholder card into Shopify's card fields and clicks Pay now once. |
| Pause | The SDK | Shopify sends the card to `checkout.pci.shopifyinc.com`. The SDK holds that request and hands you an approval link. |
| Approve | The user, on their phone | Opens the link, sees the merchant and the amount, approves with Face ID. Their device sends the real card. Shopify may ask for the card a second time; the user approves again. |
| Confirm | Your code | The page continues to the thank-you page. You read the confirmation number, report it, and end the browser. |

## 1. Start the browser

Each browser hands Playwright a page in a different way, and each one is watched and ended differently. Everything after this step takes `browser` and does not care where it came from.

<Tabs>
  <Tab title="Kernel">
    Kernel creates a Chromium with a CDP address and a live view URL. The session outlives your process until you delete it or it idles out, which is what lets a run wait on the user.

    ```ts theme={null}
    import Kernel from '@onkernel/sdk';
    import { chromium } from 'playwright-core';

    const kernel = new Kernel({ apiKey: process.env.KERNEL_API_KEY });
    const session = await kernel.browsers.create({ stealth: true, headless: false, timeout_seconds: 1800 });
    const browser = await chromium.connectOverCDP(session.cdp_ws_url);
    console.log('watch:', session.browser_live_view_url);
    ```

    <img src="https://mintcdn.com/agentcard/GVN3ZmhUebCC6YuZ/images/guides/kernel-shopify/kernel-live-view.png?fit=max&auto=format&n=GVN3ZmhUebCC6YuZ&q=85&s=6aa3007fc2bd1f312c954d79b42b3e5d" alt="Kernel's live view of the session while the approval is pending: the checkout page on Processing, the total 99 cents" width="1440" height="900" data-path="images/guides/kernel-shopify/kernel-live-view.png" />

    Kernel's `browser_live_view_url` opens the session in a tab. During the pause it shows the checkout exactly as the agent left it, which is what you show a user who asks what their agent is doing.
  </Tab>

  <Tab title="Browserbase">
    Browserbase creates a session with a `connectUrl` for Playwright, a live debugger view while it runs, and a recording afterwards. `timeout` is in seconds; the session runs until you release it or that runs out.

    ```ts theme={null}
    import Browserbase from '@browserbasehq/sdk';
    import { chromium } from 'playwright-core';

    const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
    const projectId = process.env.BROWSERBASE_PROJECT_ID;
    const session = await bb.sessions.create({ projectId, timeout: 3600 });
    const browser = await chromium.connectOverCDP(session.connectUrl);
    const live = await bb.sessions.debug(session.id);
    console.log('watch:', live.debuggerFullscreenUrl);
    console.log('recording:', `https://browserbase.com/sessions/${session.id}`);
    ```

    The Browserbase run of this guide was session `ace3f14e-91de-4940-b398-3eeff0b0c9e6`: 18 minutes 21 seconds from creation to release, 38 pages visited, status Completed on the sessions page afterwards. The recording replays the whole purchase, including the minutes the checkout sat on "Processing…" while the user decided.
  </Tab>

  <Tab title="Browser Use">
    Browser Use creates a cloud browser with a `cdpUrl` for Playwright and a `liveUrl` to watch it. `timeout` is in minutes; `proxyCountryCode` puts the browser's exit in the buyer's country, which keeps Shopify's checkout in the currency and tax rules the user expects.

    ```ts theme={null}
    import { BrowserUse } from 'browser-use-sdk/v4';
    import { chromium } from 'playwright-core';

    const bu = new BrowserUse({ apiKey: process.env.BROWSER_USE_API_KEY });
    const session = await bu.browsers.create({ proxyCountryCode: 'us', timeout: 60 });
    const browser = await chromium.connectOverCDP(session.cdpUrl);
    console.log('watch:', session.liveUrl);
    ```

    <img src="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/guides/browser-use-shopify/05-after-pay-click.png?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=7b042d149e180cfc78104648beb71f8b" alt="The checkout in the Browser Use browser after Pay now: the button reads Processing while the card request is held, the total 99 cents" width="1280" height="900" data-path="images/guides/browser-use-shopify/05-after-pay-click.png" />

    `liveUrl` shows the page only while the browser runs. Opened after `stop`, the live view says "Connection Lost", so capture what you need from the page before you end the browser.
  </Tab>

  <Tab title="Anchor">
    Anchor's `createBrowser` returns a connected Playwright `browser` and the session, with a `live_view_url`. Both timeouts are in minutes; the session ends at whichever comes first unless you end it.

    ```ts theme={null}
    import { createBrowser } from 'anchorbrowser';   // reads ANCHORBROWSER_API_KEY

    const { browser, session } = await createBrowser({
      sessionOptions: { session: { timeout: { max_duration: 60, idle_timeout: 30 } } },
    });
    const sessionId = session.data.id;
    console.log('watch:', session.data.live_view_url);
    ```

    <img src="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/guides/anchor-shopify/anchor-live-view.png?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=7da952a3c393f9aa33255ea599177f56" alt="Anchor's live view of the session after the second approval: the checkout URL has moved to post-purchase while the button still reads Processing" width="1440" height="900" data-path="images/guides/anchor-shopify/anchor-live-view.png" />

    Anchor's live view is the session's page in your own tab. The screenshot above is from the moment this guide comes back to in step 8: the URL already says the order is through, and the page has not caught up.
  </Tab>

  <Tab title="Your own browser">
    No provider: the browser is the Google Chrome on the machine that runs the script (`BROWSER_CHANNEL=chrome`), or Playwright's own Chromium after `npx playwright-core install chromium`. It dies with your process, which changes how long a run has to stay alive.

    ```ts theme={null}
    import { chromium } from 'playwright-core';

    const channel = process.env.BROWSER_CHANNEL;   // 'chrome' for the Google Chrome on this machine; unset for Playwright's own Chromium
    const browser = await chromium.launch({
      ...(channel ? { channel } : {}),
      headless: process.env.HEADED !== '1',         // HEADED=1 shows the window
    });
    ```

    | | Hosted browser | Your own browser |
    | - | - | - |
    | Watch it | The session's live view URL | `headless: false`, or screenshots |
    | Where it exits | The provider's proxy | Your machine's IP, a plain Chrome |
    | When it ends | You end the session, or its timeout does | Your process ends, or you close the browser |
    | Keep it after a pause | The session outlives your process | Your process has to stay alive |

    The last row is the one that bites. With a hosted browser you can exit your script and come back to the session. Here the browser dies with the process, so a run that is waiting on an approval or on the merchant's answer has to keep running. Headless Chrome shopped and paid on this run without a bot check; a store that shows one is a reason to run headed, or to take a hosted browser.
  </Tab>
</Tabs>

## 2. Open the checkout

Two things matter before the agent types anything. Create the context with service workers blocked, because a service worker owns requests Playwright cannot see, and the card request is one of them. And dismiss the store's cookie notice yourself, because it sits over the checkout button.

```ts theme={null}
const context = await browser.newContext({ serviceWorkers: 'block' });
const page = await context.newPage();

await page.goto('https://www.goodandbeautiful.com/products/high-school-biology-answer-key');
await page.locator('form[action="/cart/add"] button[type=submit]').first().click();
await page.goto('https://www.goodandbeautiful.com/cart');

const cookie = page.locator('button:has-text("Decline"), button:has-text("Accept")').first();
if (await cookie.isVisible()) await cookie.click();

await page.getByRole('button', { name: /check ?out/i }).filter({ visible: true }).first().click();
await page.waitForURL(/\/checkouts\//);
```

<img src="https://mintcdn.com/agentcard/GVN3ZmhUebCC6YuZ/images/guides/kernel-shopify/01-product.png?fit=max&auto=format&n=GVN3ZmhUebCC6YuZ&q=85&s=f740c24dc3d7330fb8b7c9248067e1d0" alt="The product page in the Kernel browser: High School Biology Answer Key (PDF), 99 cents, Add to Cart" width="1280" height="900" data-path="images/guides/kernel-shopify/01-product.png" />

<img src="https://mintcdn.com/agentcard/GVN3ZmhUebCC6YuZ/images/guides/kernel-shopify/02-cart.png?fit=max&auto=format&n=GVN3ZmhUebCC6YuZ&q=85&s=f582566c0067f1fe2484f9bbc8e3cc19" alt="The cart: one item, 99 cents, with the store's cookie notice over the checkout button" width="1280" height="900" data-path="images/guides/kernel-shopify/02-cart.png" />

Shopify's checkout URL is `/checkouts/cn/<token>/en-us`. A store with Shop Pay sends the cart permalink to `shop.app` first; the cart page's own checkout button lands on the store's checkout, which is where the SDK works.

## 3. Fill the checkout

The checkout is one page. Fill the contact email, choose the credit card payment method, and fill the billing address. Shopify recomputes tax for the address, so read the total after the address settles, not before: the estimate before the address said \$1.06, and California charges no tax on a digital product, so the total the user approved was \$0.99. The amount you pass is the amount the approval shows the user. Shopify's card request carries no total of its own for the SDK to compare it against, so wait until the order summary has stopped changing before you read it, and read it again right before Pay now if anything on the page changed.

```ts theme={null}
await page.fill('#email', buyer.email);
await page.locator('#basic-creditCards').check({ force: true });

const field = (name) => page.locator(`[name=${name}]:visible`).first();
await field('countryCode').selectOption({ label: 'United States' });
await field('firstName').fill(buyer.first);
await field('lastName').fill(buyer.last);
await field('address1').fill(buyer.address1);
await field('city').fill(buyer.city);
await field('zone').selectOption({ label: 'California' });
await field('postalCode').fill(buyer.zip);
await page.keyboard.press('Tab');
await page.waitForLoadState('networkidle');

const readTotal = async () => {
  const m = (await page.locator('body').innerText()).match(/Total\s*USD\s*\$([0-9]+\.[0-9]{2})/);
  return m ? Math.round(parseFloat(m[1]) * 100) : null;
};
let totalCents = await readTotal();
for (let i = 0; i < 5; i++) {              // settle: two identical reads, two seconds apart
  await page.waitForTimeout(2000);
  const again = await readTotal();
  if (again === totalCents) break;
  totalCents = again;
}
if (totalCents == null) {
  // No total, no purchase: nothing is attached yet, so the browser can go.
  await endBrowser();
  throw new Error('the checkout shows no total; stopped before attaching');
}
```

<img src="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/guides/browserbase-shopify/03-checkout-filled.png?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=a9d3e6b189e069eae8dcfcaba62a3a27" alt="The checkout filled in from the Browserbase session: email, credit card selected, billing address, and the order summary with the total" width="1280" height="900" data-path="images/guides/browserbase-shopify/03-checkout-filled.png" />

Use an email Shop Pay has never seen for the buyer, a fresh one per run. Shopify recognises an email it has seen and opens a prompt over the page, "Confirm it's you" for an email with a Shop Pay account, or its own checkout modal for one that paid on a Shopify store before, and that prompt intercepts every click until it is closed. The first Anchor run of this guide reused the Browserbase run's email and died on the card field:

```text theme={null}
locator.click: Timeout 15000ms exceeded.
  - <div data-type="modal" data-variant="checkoutModal" data-nametag="shop-portal-provider"></div> intercepts pointer events
```

Nothing was attached and nothing was charged. The second Anchor run used a new address and closed the prompt when it appeared; the companion script does both.

## 4. Attach before the card form

Attach once the billing address is in and before anything touches the card fields. The call is the one on your browser's integration page, with the parts a real purchase needs: the total you just read, the time the user gets, and a merchant result reader.

```ts theme={null}
import { VaultClient, attachToPlaywright } from '@agent-cards/sdk';

const vault = new VaultClient({
  clientId: process.env.AGENTCARD_CLIENT_ID,
  clientSecret: process.env.AGENTCARD_CLIENT_SECRET,
});
await vault.syncRegistry();

let payClickedAt = null;
let cardReplayed = false;        // true once any approval put the real card into the checkout
const checkout = await attachToPlaywright(page, {
  vault,
  user: process.env.AGENTCARD_USER_ID,
  merchant: 'www.goodandbeautiful.com',
  amount: totalCents,
  currency: 'usd',
  timeoutMs: 15 * 60 * 1000,
  payClickedAt: () => payClickedAt,
  onApprovalUrl: (url) => sendToUser(url),
  onStateChange: (state) => console.log(state.status, state.reason ?? ''),
  onEvent: (event) => { if (event.type === 'authorized') cardReplayed = true; },
  resolveMerchantResult: async () => {
    const body = await page.locator('body').innerText();
    const order = body.match(/Confirmation\s*#\s*([A-Z0-9]+)/i);
    if (order && /You.ve paid for your order/.test(body)) return { status: 'completed', orderId: order[1] };
    if (/There was a problem|declined|could not be processed/i.test(body)) return { status: 'failed' };
    return { status: 'pending' };   // a thank-you URL without the number is not a receipt yet
  },
});
```

`sendToUser` is yours: whatever already reaches the user, such as the thread your product has with them. The link opens only for the cardholder it is bound to, and it still goes to the user and nowhere else. Never hand it to the agent or log it where the agent reads.

`resolveMerchantResult` reads the merchant's answer, not the processor's. Shopify's thank-you page says `Confirmation #` and the number, then "You've paid for your order." The resolver reports `completed` only with that number in hand; the thank-you URL alone, or the page before the number paints, is `pending`. The SDK never clicks Pay for you, and neither should a retry of yours.

Leave `requireMerchantResult` off for a Shopify checkout. That option holds every further card request until you reconcile, and Shopify sends more of them right after the real card goes through; with the hold on, the SDK blocked eleven of them in two seconds and the page sat on "Processing…" until the browser was deleted. Nothing was charged, and nothing was bought either.

## 5. Pay with a placeholder card

Shopify renders each card field in its own iframe on `checkout.pci.shopifyinc.com`. The iframes are named `card-fields-number-…`, `card-fields-expiry-…`, `card-fields-verification_value-…` and `card-fields-name-…`; the suffix changes per page load, so match on the prefix. Each iframe holds a hidden honeypot input ahead of the real field; take the visible one, or the number lands in a field Shopify ignores. Type any of [Stripe's published test cards](https://docs.stripe.com/testing); the docs keep the number out of the page and the script reads it from `PLACEHOLDER_CARD_NUMBER`. The real card never enters the browser.

```ts theme={null}
const card = (prefix) =>
  page.frameLocator(`iframe[name^="card-fields-${prefix}-"]`)
    .locator('input:not([data-honeypot-field])').filter({ visible: true }).first();

const placeholder = { number: process.env.PLACEHOLDER_CARD_NUMBER, expiry: '1234', cvc: '123' };
await card('number').pressSequentially(placeholder.number);
await card('expiry').pressSequentially(placeholder.expiry);
await card('verification_value').pressSequentially(placeholder.cvc);
await card('name').pressSequentially(`${buyer.first} ${buyer.last}`);

payClickedAt = Date.now();
await page.locator('#checkout-pay-button').click();
```

<img src="https://mintcdn.com/agentcard/GVN3ZmhUebCC6YuZ/images/guides/kernel-shopify/04-placeholder-card.png?fit=max&auto=format&n=GVN3ZmhUebCC6YuZ&q=85&s=0eef0ddef6067f66f1ae06e6acc0b5f1" alt="Shopify's card fields filled with the placeholder card, Pay now below" width="1280" height="900" data-path="images/guides/kernel-shopify/04-placeholder-card.png" />

Click Pay now once. Shopify posts the card to `checkout.pci.shopifyinc.com/sessions`, the SDK pauses that request, and `onApprovalUrl` fires with the link. The button reads "Processing…" and stays that way until the user decides; they have 15 minutes.

<img src="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/guides/browserbase-shopify/05-after-pay-click.png?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=44d0b73208cc1ec55042dacdcf4aed06" alt="The checkout after Pay now in the Browserbase session: the button reads Processing while the card request is held" width="1280" height="900" data-path="images/guides/browserbase-shopify/05-after-pay-click.png" />

## 6. Deliver the approval link

`onApprovalUrl` hands your code the link. Nothing happens until a person opens it. One run of this guide printed the link to the terminal and nothing else; nobody was reading the terminal, and this is what the SDK printed fifteen minutes later:

```text theme={null}
18:00:14 state=awaiting_approval auth=cauth_73026e5483c54cd777b83801
18:00:14 APPROVAL LINK (send it to the user, never to the agent): https://vault.agentcard.sh/authorize?id=cauth_73026e5483c54cd777b83801
18:15:13 state=outcome_unknown reason=authorization_poll_failed auth=cauth_73026e5483c54cd777b83801
18:15:13 event failed "PaymentOutcomeUnknownError"
```

The authorization read `expired` afterwards, with no replay and nothing charged. The page sat on "Processing…" the whole time, and Shopify never saw a card. The SDK does not name that state `expired`: it reports `outcome_unknown` with the reason `authorization_poll_failed`, and the loop in step 8 has to confirm the expiry and end the run, or it polls a page that will never change. The first Browser Use run of this guide ended the same way with the link delivered but the user away from the phone; same output, same result. Make the delivery part of the run. The companion script runs a shell command with the link in `APPROVAL_URL` when `APPROVAL_LINK_COMMAND` is set; in your product it is whatever already reaches the user.

```ts theme={null}
import { execFile } from 'node:child_process';

onApprovalUrl: (url) => {
  if (process.env.APPROVAL_LINK_COMMAND) {
    execFile('/bin/sh', ['-c', process.env.APPROVAL_LINK_COMMAND], { env: { ...process.env, APPROVAL_URL: url } });
  }
},
```

## 7. Expect a second approval

The user sees the merchant and the amount on the approval page and confirms with Face ID. Their device sends the real card to Shopify's card session endpoint and reports the response; the SDK replays it into the paused request, and the checkout continues as if the browser had sent the real card. On the Kernel and Browser Use runs that was the end of it: on Kernel, eighteen seconds from click to approval, then three minutes of Shopify finishing the order; on Browser Use, the order confirmed thirteen seconds after the approval. On the Browserbase, Anchor and local Chrome runs Shopify asked for the card a second time, two seconds after the replay. This is the Browserbase run:

```text theme={null}
00:35:04 event card_request_paused {"url":"https://checkout.pci.shopifyinc.com/sessions","recognizer":"shopify"}
00:35:05 state=awaiting_approval auth=cauth_3d5ebc33fd5ad08245f97e2b
00:35:05 APPROVAL LINK (send it to the user, never to the agent): https://vault.agentcard.sh/authorize?id=cauth_3d5ebc33fd5ad08245f97e2b
00:47:05 event authorized {"mode":"token","authorizationId":"cauth_3d5ebc33fd5ad08245f97e2b"}
00:47:05 state=awaiting_merchant auth=cauth_3d5ebc33fd5ad08245f97e2b
00:47:07 event card_request_paused {"url":"https://checkout.pci.shopifyinc.com/sessions","recognizer":"shopify"}
00:47:08 state=awaiting_approval auth=cauth_dfdb0513d3207687b62f1358
00:47:08 APPROVAL LINK (send it to the user, never to the agent): https://vault.agentcard.sh/authorize?id=cauth_dfdb0513d3207687b62f1358
00:51:42 event authorized {"mode":"token","authorizationId":"cauth_dfdb0513d3207687b62f1358"}
00:51:42 state=awaiting_merchant auth=cauth_dfdb0513d3207687b62f1358
00:52:05 completed  order #XTLQVAF0Y  cauth_dfdb0513d3207687b62f1358
```

Shopify's first request produced a card token the checkout did not turn into a payment, so the checkout asked for the card again. Each card request is its own authorization, so the user got a second link for the same purchase. They approved it, and the order completed twenty-three seconds later. The difference between one request and two is Shopify's, and your code cannot tell in advance which it gets. Three consequences:

* A second `awaiting_approval` after an `authorized` is not an error. Tell the user that the store asked again and that it is the same purchase, and deliver the new link the same way.
* Do not click Pay again, and do not start a new checkout. The second request came from the page you already have.
* Keep the browser through it. A hosted session stays up on its own; your own browser stays up only while your process does.

One order, one charge: two approvals for one checkout are two card tokens, and Shopify used the second. The store's order confirmation email for the local Chrome run named one total, \$0.99, and one card.

The approval page is bound to the user the purchase was made for. If the user's phone is signed in to the Vault as a different account, the page says "That approval link is not valid for your account." The user signs out there, opens a Vault link your code made for their `user_id`, adds the card, and the approval link works.

## 8. Confirm the order

Once the state turns to `awaiting_merchant`, wait for the thank-you page and reconcile. End the browser when the result is `completed`, `failed` or `declined`, or when a card request the user declined or let expire ends the run. A `pending` or `outcome_unknown` result after a card was replayed means the merchant has not answered yet, and the page is where that answer appears, so the browser stays up and you reconcile again before you decide anything. Five rules for the loop:

* Read `getState()` first and stop on `declined`, `timed_out` or `cancelled`.
* Stop on an approval nobody gave, once the authorization confirms it. `outcome_unknown` with `authorization_poll_failed` while no card was ever replayed in this run means the SDK lost track of the approval; the reason is the same for an approval that expired and for a network error while the approval was still open. Retire the approval and read the authorization: `expired` or `declined` with `replay_attempted: false` ends the run with nothing charged. Any other answer is unknown, not expired: an authorization that reads `approved`, or `expired` with `replay_attempted: true`, means a card may have reached Shopify while the SDK was not listening. If an earlier card request in the same run was approved, do not stop there at all: Shopify has seen a card, so the outcome is the merchant's to tell.
* While a card request is paused on a new approval, the state is `awaiting_approval` and `reconcile()` has nothing to read yet, so wait rather than call it.
* Never give up on a clock of your own: a purchase that is still `pending` after an hour is still a purchase.
* A hosted session has a clock you do not own. Kernel's ends at `timeout_seconds` of inactivity, Browserbase's at `timeout`, Browser Use's at `timeout`, Anchor's at `max_duration` or `idle_timeout`. Set the limit well past the 15 minutes the user gets plus the minutes Shopify takes, and when the session ends anyway, stop with the purchase unknown and read the store's record instead of the page.

```ts theme={null}
let browserGone = false;
browser.on('disconnected', () => { browserGone = true; });

// Agentcard's record of one approval. orgToken() is yours: the client-credentials token of your organization.
const readAuthorization = async (id) => {
  const res = await fetch(`https://api.agentcard.sh/api/v2/checkout/authorizations/${id}`, {
    headers: { Authorization: `Bearer ${await orgToken()}` },
  });
  return res.ok ? res.json() : null;
};

// An approval the SDK stopped listening for is a live link in the user's hands. Cancel it and read it back until it
// no longer awaits approval; a cancel that failed is retried, never assumed. An approval lives 15 minutes, so the
// attempt stops a minute past that: by then the link is dead either way, and an unconfirmed read returns null.
const retireApproval = async (id) => {
  const deadline = Date.now() + 16 * 60 * 1000;
  while (Date.now() < deadline) {
    const auth = await readAuthorization(id).catch(() => null);
    if (auth && auth.status !== 'awaiting_approval') return auth;
    await vault.cancelAuthorization(id).catch((err) => console.error('cancel failed, retrying:', err.message));
    await new Promise((resolve) => setTimeout(resolve, 5_000));
  }
  return null;
};

const settled = (s) => ['completed', 'failed', 'declined', 'expired'].includes(s.status);
let result = checkout.getState();
while (!settled(result)) {
  const state = checkout.getState();
  if (['declined', 'timed_out', 'cancelled', 'failed'].includes(state.status)) { result = state; break; }
  if (state.status === 'outcome_unknown' && state.reason === 'authorization_poll_failed' && !cardReplayed) {
    const auth = await retireApproval(state.authorizationId);
    const untouched = auth && ['expired', 'declined'].includes(auth.status) && auth.replay_attempted === false;
    result = untouched
      ? { ...state, status: auth.status }              // confirmed: no approval, no card replayed, nothing charged
      : state;                                         // unconfirmed, approved or replayed: unknown, the store's record decides
    break;
  }
  if (browserGone) { result = { ...state, status: 'outcome_unknown', reason: 'browser_ended' }; break; }
  if (state.status === 'awaiting_approval') { await page.waitForTimeout(15_000).catch(() => {}); continue; }
  result = await checkout.reconcile().catch(() => result);
  if (!settled(result)) await page.waitForTimeout(15_000).catch(() => {});
}
console.log(result.status, result.reason ?? '', result.orderId ?? '', result.authorizationId ?? '');

if (result.status === 'outcome_unknown') {
  // The page is gone or never answered. The store's record decides, not a second Pay.
  const order = await findOrderInStoreRecord(buyer.email);   // yours: the order email, or the order status page
  console.log(order ? `store record: order ${order}` : 'no store record yet; ask again later, buy nothing');
}
if (!browserGone) await endBrowser();
```

An authorization that still reads `awaiting_approval` after the poll failed is a live link in the user's hands with nothing listening for it. `retireApproval` cancels it and reads it back until [Get an authorization](/api-reference/vault/authorizations-get) no longer says `awaiting_approval`; a cancel that fails is retried. The attempt is bounded by the approval's own life: an approval lives 15 minutes, so after 16 the link is dead whether or not the read ever answered, and a read that never answered leaves the result unknown. The run then goes on to the store's record and to closing the browser instead of waiting on an API that is not answering. The `status` alone is not enough to call the run uncharged: `replay_attempted` says whether a device sent the card, and only `false` rules a charge out. On the first Browser Use run the read returned `expired` with `replay_attempted: false`, and on the run that bought the PDF `approved` with `replay_attempted: true`.

`findOrderInStoreRecord` is yours, because the record is the store's: Shopify emails an order confirmation to the buyer's address within seconds of the charge, and the order status page says the same. The Anchor tab below is a run that needed it. Agentcard's side of the story is the authorization: [Get an authorization](/api-reference/vault/authorizations-get) says whether the card was replayed and what the processor reported, and the `checkout_authorization.expired` webhook reports an approval nobody gave. Neither replaces the store's record for whether an order exists. A purchase that is still unknown is reported to the user as unknown; a new checkout starts only after the store confirms there is no order.

`endBrowser` is the one line that differs per browser, and each tab shows what its run printed at the end.

<Tabs>
  <Tab title="Kernel">
    ```ts theme={null}
    const endBrowser = async () => {
      await browser.close();
      await kernel.browsers.deleteByID(session.session_id);
    };
    ```

    <img src="https://mintcdn.com/agentcard/GVN3ZmhUebCC6YuZ/images/guides/kernel-shopify/07-merchant-result.png?fit=max&auto=format&n=GVN3ZmhUebCC6YuZ&q=85&s=6438b57fc2f1110f6068cadd45b9ed53" alt="Shopify's thank-you page in the Kernel browser: Confirmation #5CQFGUXJX, You've paid for your order, and the PDF ready to download" width="1280" height="900" data-path="images/guides/kernel-shopify/07-merchant-result.png" />

    ```text theme={null}
    03:33:34 reconcile → completed  cauth_70935e6a0f0dfa0e4d6536d5 5CQFGUXJX
    03:33:34 final url https://www.goodandbeautiful.com/checkouts/cn/hWNHbYpLF80OWFp1g1hLUaHK/en-us/post-purchase
    03:33:34 kernel session deleted; outcome completed
    ```

    A session you do not delete ends on its own at `timeout_seconds` of inactivity, which is the right thing when the merchant has not answered: the page is still there to read.
  </Tab>

  <Tab title="Browserbase">
    ```ts theme={null}
    const endBrowser = async () => {
      await browser.close();
      await bb.sessions.update(session.id, { projectId, status: 'REQUEST_RELEASE' });
    };
    ```

    <img src="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/guides/browserbase-shopify/07-merchant-result.png?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=3e7a3ce95babad886e29c28626799be9" alt="Shopify's thank-you page in the Browserbase session: Confirmation #XTLQVAF0Y, You've paid for your order, and the PDF ready to download" width="1280" height="900" data-path="images/guides/browserbase-shopify/07-merchant-result.png" />

    ```text theme={null}
    00:52:05 completed  order #XTLQVAF0Y  cauth_dfdb0513d3207687b62f1358
    00:52:05 browser ended
    ```

    The release is what stops the meter; the recording stays on the sessions page afterwards, and the page list there shows the 38 pages the purchase touched, two of them on `shop.app`.
  </Tab>

  <Tab title="Browser Use">
    ```ts theme={null}
    const endBrowser = async () => {
      await browser.close();
      await bu.browsers.stop(session.id);
    };
    ```

    <img src="https://mintcdn.com/agentcard/h_lTE0-Hdhuo7qX_/images/guides/browser-use-shopify/07-merchant-result.png?fit=max&auto=format&n=h_lTE0-Hdhuo7qX_&q=85&s=0d7352a9b998844a333965025fedf785" alt="Shopify's thank-you page in the Browser Use browser: Confirmation #TAKMQEGA8, You've paid for your order, and the PDF ready to download" width="1280" height="900" data-path="images/guides/browser-use-shopify/07-merchant-result.png" />

    ```text theme={null}
    13:17:57 state=awaiting_approval auth=cauth_00a6679df8ce965dec5a7708
    13:19:15 event authorized {"mode":"token","authorizationId":"cauth_00a6679df8ce965dec5a7708"}
    13:19:15 state=awaiting_merchant auth=cauth_00a6679df8ce965dec5a7708
    13:19:28 state=completed auth=cauth_00a6679df8ce965dec5a7708
    13:19:29 completed  order #TAKMQEGA8  cauth_00a6679df8ce965dec5a7708
    13:19:29 browser ended
    ```

    One approval, one card request, and the confirmation number thirteen seconds later. The first Browser Use run of this guide ended in step 6 instead, with the approval link expired and nothing charged. `stop` ends the browser at once; a browser you do not stop runs to its `timeout` in minutes.
  </Tab>

  <Tab title="Anchor">
    ```ts theme={null}
    const endBrowser = async () => {
      await browser.close();
      await fetch(`https://api.anchorbrowser.io/v1/sessions/${sessionId}`, {
        method: 'DELETE',
        headers: { 'anchor-api-key': process.env.ANCHORBROWSER_API_KEY },
      });
    };
    ```

    The Anchor run is the one where the page was not enough. The user approved the second card request at 01:19:53, the store's download email for order 33230852 arrived twelve seconds later, and the checkout URL moved to `/post-purchase`. The page itself never repainted: the button stayed on "Processing…", no `Confirmation #` appeared, and every reconcile for the next four minutes read the same thing.

    ```text theme={null}
    01:19:53 event authorized {"mode":"token","authorizationId":"cauth_dbf12d7cf4c5ca476411d327"}
    01:19:53 state=awaiting_merchant auth=cauth_dbf12d7cf4c5ca476411d327
    01:22:43 state=awaiting_merchant reason=merchant_not_confirmed auth=cauth_dbf12d7cf4c5ca476411d327
    01:23:02 state=awaiting_merchant reason=merchant_not_confirmed auth=cauth_dbf12d7cf4c5ca476411d327
    ```

    The session was ended by hand once the store's record was in hand. The lesson is the one step 8 opens with: `pending` is not a failure, and the browser stays up until something else says the order is through. The store's own record, an order email or the order status page, is that something, and it is what you report to the user. The companion script now reloads the thank-you URL once when the receipt text is missing from it; a thank-you page is a plain GET, so a reload never re-submits the checkout.
  </Tab>

  <Tab title="Your own browser">
    ```ts theme={null}
    const endBrowser = async () => {
      await browser.close();
    };
    ```

    <img src="https://mintcdn.com/agentcard/K-bylD7afogWRSuH/images/guides/own-browser-shopify/07-order-status.png?fit=max&auto=format&n=K-bylD7afogWRSuH&q=85&s=12951a96608b1d0e76ad60283c25907e" alt="Shopify's order status page: Order 33230218, Confirmed Oct 5" width="1280" height="200" data-path="images/guides/own-browser-shopify/07-order-status.png" />

    The local Chrome run bought the PDF through two approvals, and the store's two emails arrived within ten seconds of the second one: the order confirmation with the total and the card's last four digits, and the download notice with order 33230218. Closing the browser is the whole teardown, which is also the risk: a process that exits while the merchant's answer is pending takes the page with it, and the order it would have reported stays unreported.
  </Tab>
</Tabs>

`completed` with a confirmation number is the only outcome you report as a purchase. `pending` and `outcome_unknown` mean keep the browser and look again; a missing receipt is not a failed payment, and clicking Pay again can charge twice. `declined` means the user said no or the processor refused; nothing was charged.

## Run it

The whole run is one file in [tiny-agent-company/kernel-shopify-purchase](https://github.com/tiny-agent-company/kernel-shopify-purchase), the script every tab of this guide was captured from. `BROWSER` picks the browser; the rest of the script is shared.

```bash theme={null}
git clone https://github.com/tiny-agent-company/kernel-shopify-purchase
cd kernel-shopify-purchase && npm install
cp .env.example .env     # BROWSER, that browser's key, Agentcard credentials, user id, product, buyer, APPROVAL_LINK_COMMAND
set -a; source .env; set +a
BROWSER=browserbase npm run buy     # kernel | browserbase | browser-use | anchor | local
```

The script delivers the approval link with `APPROVAL_LINK_COMMAND` and prints it; everything else is automatic, and a screenshot of every step lands in `shots-<browser>/`. `HEADED=1 BROWSER=local npm run buy` shows the local window.

## Handle a run that stalls

The states in the table are the `status` values `onStateChange` prints, in the shape `state=<status> reason=<reason>`.

| What you see | Why | What to do |
| - | - | - |
| Pay now does nothing and no `awaiting_approval` | A field Shopify requires is empty, or the card went into the hidden honeypot input | Read the checkout's inline error; type into the visible input of each card iframe |
| A click on the card field times out with `shop-portal-provider … intercepts pointer events`, or a "Confirm it's you" prompt | Shop Pay recognises the buyer's email | Use an email Shop Pay has never seen, and close the prompt before typing the card |
| `outcome_unknown` with `authorization_poll_failed`, no `authorized` before it | Nobody opened the approval link within 15 minutes, or the SDK could not reach Agentcard while the approval was open | Cancel the approval and read the authorization until it no longer awaits approval. `expired` with `replay_attempted: false`: nothing was charged, deliver the link from `onApprovalUrl` next time and run again. Anything else: report the purchase as unknown and read the store's record |
| A second `awaiting_approval` right after `authorized` | Shopify asked for the card again | Deliver the new link, say it is the same purchase, click nothing |
| `awaiting_merchant` for minutes, the page on "Processing…" | `requireMerchantResult` is on and the SDK is blocking Shopify's follow-up card requests | Attach without it; read the order with `resolveMerchantResult` and `reconcile()` |
| `awaiting_merchant` with `merchant_not_confirmed` after the URL moved to `/post-purchase` | The page has not repainted the receipt | Keep the browser; read the store's order email or status page; reload the thank-you URL once |
| `unsupported` right after the click | The request was not Shopify's card session request: Shop Pay or PayPal took the payment | Pick the credit card payment method before attaching; express checkout buttons bypass the card form |
| `declined` with an amount reason | The total you passed disagrees with what Shopify charges | Read the total after the billing address; tax changes it |
| "That approval link is not valid for your account" on the phone | The phone's Vault is signed in as another user | Sign out there, open a Vault link for this `user_id`, add the card, open the approval link again |
| The hosted session ends while the result is `pending` | The session reached its timeout before Shopify answered | Report the purchase as unknown; read the store's order email or status page and the authorization; start no new checkout until the store says there is no order |
| The process exits and the page is gone | Your own browser's script ended while the merchant's answer was pending | Keep reconciling until `completed`, `failed` or `declined`; read the store's record before deciding |
| A bot check or a blank checkout in headless Chrome | The store treats headless Chrome on your IP as a bot | Run with `HEADED=1`, or take a hosted browser |

## Where to go next

* [Completing a purchase](/vault/completing-a-purchase) for the webhooks that tell your server the same story.
* [Kernel](/vault/integrations/agent-browsers/kernel), [Browserbase](/vault/integrations/agent-browsers/browserbase), [Browser Use](/vault/integrations/agent-browsers/browser-use) and [Anchor](/vault/integrations/agent-browsers/anchor) for each provider's own integration, including the ones where the provider intercepts the card at its network edge and no SDK is attached.
* [Your own browser](/vault/integrations/agent-browsers/your-own) for the raw CDP level and for doing your own interception.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.