> ## 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.

# React Native app

> Sandbox walkthrough for a React Native app: connect a test user, render the Agentcard wallet with @agentcard/wallet-react-native, add test card 4242, and let the user's agent buy over MCP up to the sandbox wall.

export const BuildWithAI = ({prompt, tools}) => {
  const show = t => !tools || tools.includes(t);
  const copyPrompt = () => {
    if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(prompt);
  };
  const copyWithFlip = e => {
    copyPrompt();
    const label = e.currentTarget.querySelector('[data-label]');
    if (!label || label.getAttribute('data-copied') === '1') return;
    const original = label.textContent;
    label.setAttribute('data-copied', '1');
    label.textContent = 'Copied';
    setTimeout(() => {
      label.textContent = original;
      label.removeAttribute('data-copied');
    }, 2500);
  };
  const openPrefilled = base => {
    window.open(base + encodeURIComponent(prompt), '_blank');
  };
  const copyThenOpen = url => {
    copyPrompt();
    window.open(url, '_blank');
  };
  const chip = {
    display: 'inline-flex',
    alignItems: 'center',
    gap: '7px',
    padding: '7px 13px',
    margin: 0,
    borderRadius: '10px',
    border: '1px solid rgba(128,128,128,0.35)',
    background: 'transparent',
    color: 'inherit',
    font: 'inherit',
    fontSize: '14px',
    lineHeight: 1.2,
    cursor: 'pointer'
  };
  const iconProps = {
    width: 15,
    height: 15,
    viewBox: '0 0 24 24',
    fill: 'none',
    stroke: 'currentColor',
    strokeWidth: 2,
    strokeLinecap: 'round',
    strokeLinejoin: 'round',
    style: {
      flex: 'none',
      opacity: 0.85
    },
    'aria-hidden': true
  };
  return <div style={{
    border: '1px solid rgba(128,128,128,0.25)',
    borderRadius: '14px',
    background: 'rgba(128,128,128,0.06)',
    padding: '16px 18px',
    margin: '20px 0'
  }}>
      <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    fontWeight: 600
  }}>
        <svg {...iconProps}>
          <path d="M12 2 2 7l10 5 10-5-10-5Z" />
          <path d="m2 12 10 5 10-5" />
          <path d="m2 17 10 5 10-5" />
        </svg>
        <span>Build this with AI</span>
      </div>
      <p style={{
    margin: '6px 0 14px',
    fontSize: '13.5px',
    opacity: 0.65
  }}>
        Open a pre-built prompt in your preferred AI tool to generate a complete working integration.
      </p>
      <div style={{
    display: 'flex',
    flexWrap: 'wrap',
    gap: '8px'
  }}>
        {show('claude') && <button type="button" style={chip} onClick={() => openPrefilled('https://claude.ai/new?q=')}>
            <svg {...iconProps}>
              <path d="M12 3l2.1 6.9L21 12l-6.9 2.1L12 21l-2.1-6.9L3 12l6.9-2.1L12 3Z" />
            </svg>
            <span data-label>Open in Claude</span>
          </button>}
        {show('lovable') && <button type="button" style={chip} onClick={() => openPrefilled('https://lovable.dev/?autosubmit=true#prompt=')}>
            <svg {...iconProps}>
              <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
            </svg>
            <span data-label>Open in Lovable</span>
          </button>}
        {show('chatgpt') && <button type="button" style={chip} onClick={() => copyThenOpen('https://chatgpt.com/')}>
            <svg {...iconProps}>
              <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
            </svg>
            <span data-label>Open in ChatGPT</span>
          </button>}
        {show('cursor') && <button type="button" style={chip} onClick={copyWithFlip}>
            <svg {...iconProps}>
              <path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3Z" />
            </svg>
            <span data-label>Copy for Cursor</span>
          </button>}
        {show('copilot') && <button type="button" style={chip} onClick={copyWithFlip}>
            <svg {...iconProps}>
              <path d="m16 18 6-6-6-6" />
              <path d="m8 6-6 6 6 6" />
            </svg>
            <span data-label>Copy for Copilot</span>
          </button>}
        {show('claude-code') && <button type="button" style={chip} onClick={copyWithFlip}>
            <svg {...iconProps}>
              <path d="m4 17 6-6-6-6" />
              <path d="M12 19h8" />
            </svg>
            <span data-label>Copy for Claude Code</span>
          </button>}
      </div>
    </div>;
};

This quickstart is for a React Native app. The wallet is one component: you give it a wallet link your server created, it renders the wallet, and you get the same events the web SDK fires. By the end, a test user has added a card inside your app, and their agent has run a real purchase conversation at Amazon right up to the sandbox wall. About fifteen minutes, all in sandbox, so no email is sent, no money moves, and the verification code is always `111111`.

If your app is native Swift, use the [iOS quickstart](/get-started/quickstarts/ios-app) instead; [Choose a quickstart](/get-started/quickstarts) has the rest.

<BuildWithAI prompt={"Build the Agentcard integration for a React Native app, following\nhttps://docs.agentcard.sh/get-started/quickstarts/react-native-app and the pages it links.\nGet sandbox credentials at https://app.agentcard.sh (Settings → Developers → Credentials).\nImplement the five steps the quickstart walks: credentials, connect a user, the wallet for this surface, the agent buying, webhooks.\nRun every step for real in sandbox as you build (the one-time code is 111111 and the test card is 4242 4242 4242 4242).\nObey every \"Rules for your agent\" block on the pages you use.\nThe integration passes when the purchase confirm comes back declined with the sandbox-mode reply:\nthat decline is the finish line, and in production the same call places the order.\nAsk me only for values you cannot derive: my credentials and which email to connect."} />

## Prerequisites

* An Agentcard organization with sandbox credentials. In the [dashboard](https://app.agentcard.sh), open **Settings → Developers → Credentials**; if there's no client yet, **Implement Agentcard** in the same menu creates one.
* `curl`, Node 18+, or Python 3.8+ with `requests`, whichever you prefer. Every call on this page is shown in all three.

- A React Native project with `react-native-webview`, and access to the `@agentcard/wallet-react-native` package. The package is private while the SDK is in early access, so the install fails until your team has access: write to us and we'll set you up.

<Steps>
  <Step title="Get your sandbox credentials">
    Your sandbox `client_id` and `client_secret` are in the [dashboard](https://app.agentcard.sh) under **Settings → Developers → Credentials**. Sandbox or production follows the credential, not the URL: there is one API at `api.agentcard.sh`, and a sandbox credential makes every call on this page a sandbox call, so nothing is emailed, nothing is texted, and no money moves.

    Exchange the credentials for a bearer token. The request is form-encoded, per OAuth:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/api/v2/oauth/token \
        -d grant_type=client_credentials \
        -d client_id=YOUR_CLIENT_ID \
        -d client_secret=YOUR_CLIENT_SECRET
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/api/v2/oauth/token", {
        method: "POST",
        body: new URLSearchParams({
          grant_type: "client_credentials",
          client_id: "YOUR_CLIENT_ID",
          client_secret: "YOUR_CLIENT_SECRET",
        }),
      });
      const { access_token: orgToken } = await res.json();
      ```

      ```python Python theme={null}
      import requests

      res = requests.post(
          "https://api.agentcard.sh/api/v2/oauth/token",
          data={
              "grant_type": "client_credentials",
              "client_id": "YOUR_CLIENT_ID",
              "client_secret": "YOUR_CLIENT_SECRET",
          },
      )
      org_token = res.json()["access_token"]
      ```
    </CodeGroup>

    The token comes back with its lifetime:

    ```json Output theme={null}
    {
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "token_type": "Bearer",
      "expires_in": 3600,
      "scope": "api"
    }
    ```

    Export it as `ORG_TOKEN`; it's the `Authorization: Bearer` on every server call below. It lasts an hour and there's no refresh token, so when it expires you request a new one the same way.

    While you have the token, give the last step something to read: create a sandbox webhook destination. Events are only recorded when your organization has a destination to deliver them to, so a destination created after the fact shows nothing. Skip this if you already have one in sandbox.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/api/v2/webhook_endpoints \
        -H "Authorization: Bearer $ORG_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"url": "https://app.example.com/agentcard/webhooks", "enabled_events": ["*"]}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/api/v2/webhook_endpoints", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${ORG_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ url: "https://app.example.com/agentcard/webhooks", enabled_events: ["*"] }),
      });
      const endpoint = await res.json();
      ```

      ```python Python theme={null}
      import requests

      res = requests.post(
          "https://api.agentcard.sh/api/v2/webhook_endpoints",
          headers={"Authorization": f"Bearer {ORG_TOKEN}"},
          json={"url": "https://app.example.com/agentcard/webhooks", "enabled_events": ["*"]},
      )
      endpoint = res.json()
      ```
    </CodeGroup>

    `url` is any public `https` address you can watch: your own endpoint, or a request-inspection service while you test. It doesn't have to answer 2xx for this walkthrough, because you'll read the deliveries in the dashboard. The response carries the signing secret once, and `livemode: false` tells you the token was a sandbox one:

    ```json Output theme={null}
    {
      "id": "cmf3s1w7k0005l8m9dh4e2gq8",
      "object": "webhook_endpoint",
      "url": "https://app.example.com/agentcard/webhooks",
      "enabled_events": ["*"],
      "status": "active",
      "secret": "whsec_4f1e9c...",
      "livemode": false,
      ...
    }
    ```

    You can do the same in the dashboard under **Settings → Developers → Webhooks** with **Add destination**, with the **Live mode** switch off so the destination is a sandbox one. Destinations belong to one mode and receive only that mode's events.
  </Step>

  <Step title="Connect a test user">
    Start a connection for a test user. In sandbox nothing is actually sent, so any email works, and sandbox identities are isolated: connecting `testuser@example.com` here can never touch a real account with that address.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/api/v2/connect/start \
        -H "Authorization: Bearer $ORG_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"email": "testuser@example.com", "external_user_id": "user_8213"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/api/v2/connect/start", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${ORG_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ email: "testuser@example.com", external_user_id: "user_8213" }),
      });
      const attempt = await res.json();
      ```

      ```python Python theme={null}
      import requests

      res = requests.post(
          "https://api.agentcard.sh/api/v2/connect/start",
          headers={"Authorization": f"Bearer {ORG_TOKEN}"},
          json={"email": "testuser@example.com", "external_user_id": "user_8213"},
      )
      attempt = res.json()
      ```
    </CodeGroup>

    Send `phone` instead of `email` to connect by text message; the call takes exactly one of the two. `external_user_id` is optional: it's your own id for the user, and it comes back on webhooks so you can match them up. The response is the attempt, good for ten minutes:

    ```json Output theme={null}
    {
      "object": "connect_attempt",
      "id": "cmf3q8t2x0001l8m9h2kd7v4s",
      "channel": "email",
      "expires_at": "2026-08-20T17:42:11.000Z"
    }
    ```

    Verify it with the sandbox code, which is always `111111`:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/api/v2/connect/verify \
        -H "Authorization: Bearer $ORG_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"connect_id": "CONNECT_ATTEMPT_ID", "code": "111111"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/api/v2/connect/verify", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${ORG_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ connect_id: attempt.id, code: "111111" }),
      });
      const connection = await res.json();
      ```

      ```python Python theme={null}
      res = requests.post(
          "https://api.agentcard.sh/api/v2/connect/verify",
          headers={"Authorization": f"Bearer {ORG_TOKEN}"},
          json={"connect_id": attempt["id"], "code": "111111"},
      )
      connection = res.json()
      ```
    </CodeGroup>

    The response is the connection: the user's id, an `access_token`, and a `refresh_token`:

    ```json Output theme={null}
    {
      "object": "connection",
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "refresh_token": "acr_Qm5xT8vZ2kLp9wRf...",
      "token_type": "Bearer",
      "expires_in": 3600,
      "user": {
        "id": "cmf3qk5ab0002l8m9x1w2ab3c",
        "email": "testuser@example.com",
        "phone": null
      }
    }
    ```

    Store all three. `user.id` is what you create wallet links for. The `access_token` is the user's connection token: it's the bearer the user's agent buys with in step 4, over MCP or `POST /buy`, so export it as `USER_ACCESS_TOKEN` now. It lasts an hour; the `refresh_token` rotates it (`POST /api/v2/connect/refresh`), and each refresh returns a new pair and invalidates the old one. Your first webhook, `connection.created`, fires here.

    Then record the user's authorization. Consent is what makes the user's wallet available to your product: wallet links can't be created for a user until it's on file, and the call is idempotent, so a retry updates the record instead of duplicating it. In your product the wallet shows the user a consent screen; in this walkthrough you record it directly:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/api/v2/connect/consent \
        -H "Authorization: Bearer $ORG_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"user_id": "USER_ID"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/api/v2/connect/consent", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${ORG_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ user_id: connection.user.id }),
      });
      const consent = await res.json();
      ```

      ```python Python theme={null}
      res = requests.post(
          "https://api.agentcard.sh/api/v2/connect/consent",
          headers={"Authorization": f"Bearer {ORG_TOKEN}"},
          json={"user_id": connection["user"]["id"]},
      )
      consent = res.json()
      ```
    </CodeGroup>

    ```json Output theme={null}
    {
      "object": "consent",
      "id": "cmf3qm9cd0003l8m9p4q6rs7t",
      "user_id": "cmf3qk5ab0002l8m9x1w2ab3c",
      "terms_version": null,
      "created_at": "2026-08-20T17:33:04.000Z"
    }
    ```

    That's the whole server side of connecting a user. [Connect users](/connect/users) covers the same calls with production in mind: where each credential lives, refreshing sessions, and the errors you can get back.
  </Step>

  <Step title="Render the wallet">
    Install the component and its peer dependency:

    ```bash theme={null}
    npm install @agentcard/wallet-react-native react-native-webview
    ```

    The component needs a wallet link for the user who is opening it. Create links on your server and give the app only the `url`; the org token never ships in the app bundle.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/api/v2/wallet_links \
        -H "Authorization: Bearer $ORG_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"user_id": "USER_ID"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/api/v2/wallet_links", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${ORG_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ user_id: connection.user.id }),
      });
      const link = await res.json();
      ```

      ```python Python theme={null}
      res = requests.post(
          "https://api.agentcard.sh/api/v2/wallet_links",
          headers={"Authorization": f"Bearer {ORG_TOKEN}"},
          json={"user_id": connection["user"]["id"]},
      )
      link = res.json()
      ```
    </CodeGroup>

    ```json Output theme={null}
    {
      "object": "wallet_link",
      "id": "wl_3c9f1a7e2b8d4f60a1c5e9b2",
      "user_id": "cmf3qk5ab0002l8m9x1w2ab3c",
      "status": "active",
      "url": "https://app.agentcard.sh/w/wl_3c9f1a7e2b8d4f60a1c5e9b2.Xk9Qp2...",
      "expires_at": "2026-08-20T17:52:04.000Z",
      "test_mode": true
    }
    ```

    Links expire after 15 minutes by default (`expires_in` accepts 60 to 86400 seconds), so in your app the server creates a fresh one each time the wallet is about to render. For this walkthrough, paste the `url` into the screen that renders it:

    ```tsx theme={null}
    import { AgentcardWallet } from "@agentcard/wallet-react-native";

    <AgentcardWallet
      link={walletLinkUrl}
      onSuccess={(data) => {
        // data.type is "card_attached" or "payment_completed"
      }}
      onTokenExpired={() => {
        // fetch a fresh wallet link from your server and swap the `link` prop
      }}
    />
    ```

    The component accepts `link` as either the full URL or the raw token, and `style` sizes the view. Run the app and open that screen. Add a card with the test number `4242 4242 4242 4242`, any future expiry, any CVC. `onSuccess` fires with `card_attached`, and the `connected_card.updated` webhook goes out. Treat the component event as a UI signal and the webhook as the record.

    When the link or its session dies, `onTokenExpired` fires: fetch a fresh wallet link from your server and swap the `link` prop rather than reusing the old one. A changed link is a new session, so the component remounts its view.

    The same component can open onto a pay sheet instead of the wallet home, by passing `pay={{ amountCents: 1275, merchant: "Wandy's" }}`. That's a different job: the user approving one specific charge your app already priced, with a card from their wallet. What comes next is the agent going to a merchant and buying something for them, which is `buy`.
  </Step>

  <Step title="Let the agent buy">
    Your agent runs behind the app, on your server, and holds one MCP client per user with that user's connection token from step 2. The app never sees the token.

    The user's agent talks to Agentcard directly over MCP: one MCP client per user, pointed at `https://mcp.agentcard.sh/mcp` with that user's connection `access_token` from step 2 as the bearer. The bearer decides whose cards and purchases the agent can see, so a client shared across users would let one user's session act as another. Don't configure OAuth on the client: the connection token is the sign-in, and a user who ever sees a "Connect your account" page at `mcp.agentcard.sh` is a sign the client was wired for OAuth discovery instead.

    The fastest way to watch the loop is to add the server to Claude Code with the token:

    ```bash theme={null}
    claude mcp add agentcard-user --transport http https://mcp.agentcard.sh/mcp \
      --header "Authorization: Bearer CONNECTION_ACCESS_TOKEN"
    ```

    In your own agent runtime the same thing is a client constructor, and any spec-compliant MCP client over Streamable HTTP works:

    ```typescript theme={null}
    import { Client } from "@modelcontextprotocol/sdk/client/index.js";
    import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

    const transport = new StreamableHTTPClientTransport(
      new URL("https://mcp.agentcard.sh/mcp"),
      { requestInit: { headers: { Authorization: `Bearer ${user.agentcardAccessToken}` } } },
    );
    const client = new Client({ name: "your-app", version: "1.0.0" });
    await client.connect(transport);
    const { tools } = await client.listTools();
    ```

    `listTools()` returns the user's tools with `buy` among them; register whatever it returns rather than a hardcoded list, so tools Agentcard ships later appear without a deploy. Now ask the agent for something:

    ```text Prompt theme={null}
    buy a phone charger from Amazon and ship it to 548 Market St, San Francisco, CA 94104
    ```

    The agent calls `buy` with that request. The tool runs the same purchase loop as `POST /buy`, except that it's conversational: each call returns the assistant's turn as prose and a `conversation_id` to thread back.

    ```json Output theme={null}
    {
      "status": "assistant_turn",
      "conversation_id": "cmemw5k2p00b7",
      "message": "I found a few options at Amazon. The Anker 30W USB-C charger is $15.99 and can arrive tomorrow. Want that one, or should I list alternatives?",
      "messages": ["I found a few options at Amazon. ..."]
    }
    ```

    The agent shows you the message and sends your reply back, word for word, on the same `conversation_id`. Answer until it shows the cart with the exact total and asks whether to place the order, then say yes. Over HTTP that confirmation is a cart hash; over MCP the user's yes is relayed in words, which is why the agent must never rewrite a reply into a fresh order command. Your yes is where sandbox stops. The tool's last turn explains why, in its own words each time:

    ```json Output theme={null}
    {
      "status": "assistant_turn",
      "conversation_id": "cmemw5k2p00b7",
      "message": "I can't place this one. This connection is in sandbox mode, and sandbox test cards can't pay real merchants, so a live Amazon order can't go through. On a production connection this same confirmation would place it."
    }
    ```

    <Check>
      That refusal is the finish line, not a failure. You ran the whole purchase contract: an ask, a real cart at a real merchant with an exact total, and a confirmation that verified before anything moved. Sandbox stops at the money on purpose, because its test cards can never charge a real merchant, so no sandbox call ever places an order, and retrying this confirm on a sandbox connection always returns the same refusal. On a production connection this exact confirm is the one that places the order: Agentcard reserves the amount, issues a one-time card funded from the wallet, pays the merchant, and returns the receipt.
    </Check>

    That's the whole wiring for the agent: one client per user, the connection token as the bearer, and `buy` as the surface. [Buy through MCP](/purchase/mcp-for-agents) carries the rest of what a production agent needs: the other tools it gets, approvals, token refresh on `401`, and the rules for relaying the conversation.
  </Step>

  <Step title="See what happened">
    Open the [dashboard](https://app.agentcard.sh) with the **Live mode** switch off, go to **Settings → Developers → Webhooks**, pick the sandbox destination from step 1, and open its **Deliveries** tab. Every event from this walkthrough is there with its payload and the response code your destination answered. From the terminal, `agent-cards companies webhooks deliveries ENDPOINT_ID` lists the same rows.

    Every event arrives in the same envelope, and `livemode` is `false` because the credential behind it was a sandbox one:

    ```json connection.created theme={null}
    {
      "id": "evt_6f2c9a1d4e8b3f07a2c5d9e1",
      "type": "connection.created",
      "created": 1755711184,
      "livemode": false,
      "data": {
        "user_id": "cmf3qk5ab0002l8m9x1w2ab3c",
        "channel": "email",
        "external_user_id": "user_8213",
        "client_id": "5f1e8d2c-9a3b-4c7d-8e6f-1a2b3c4d5e6f"
      }
    }
    ```

    What you'll find, in order: `connection.created` from step 2, `wallet_link.opened` the first time the wallet opened from its link, and `connected_card.updated` when the card was added. On the company-funded quickstart it's `cardholder.created` from step 2 instead. Nothing arrived from the purchase, and that's correct: sandbox stopped the confirm before a card existed, so the `transaction.*` events a live order produces never fired. To rehearse those in sandbox, `test_charge` on the organization MCP server simulates a full charge against a sandbox card and sends `transaction.authorized`, `transaction.cleared`, and `card.closed`; [Test in sandbox](/ship/test-in-sandbox) has every knob.

    In production your server listens for these instead of reading them in the dashboard. The SDK callbacks and the conversation are UI signals; webhooks are the record. Deliveries are at least once, so deduplicate on `id`, and verify the `AgentCard-Signature` header against the raw request body: [Webhooks](/ship/webhooks) shows the scheme and lists every event.
  </Step>
</Steps>

## Next steps

You connected a user, they added a card inside your app, and their agent ran a real purchase conversation to the confirm. In production the same five steps are your integration; only the credentials change.

<CardGroup cols={2}>
  <Card title="React Native" href="/platforms/react-native">
    The full component surface, bank approvals in the system browser, and the rules for your agent.
  </Card>

  <Card title="Buy through MCP" href="/purchase/mcp-for-agents">
    Everything the agent's `buy` loop needs in production: tools, approvals, token refresh, and relay rules.
  </Card>

  <Card title="Webhooks" href="/ship/webhooks">
    Verify signatures and handle every event on your server instead of reading them in the dashboard.
  </Card>

  <Card title="Go live" href="/ship/go-live">
    Subscribe, switch to production credentials, and run the loop once with real money.
  </Card>
</CardGroup>
