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

# iOS app

> Sandbox walkthrough for a native iOS app: connect a test user, present the Agentcard wallet sheet with AgentcardWalletKit, add test card 4242 in the simulator, 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 native iOS app. The wallet is a SwiftUI sheet from the AgentcardWalletKit package: you pass it a wallet link your server created and present it like any other view. It talks to the API natively, and only the card entry step runs in a secure web view, so card numbers never touch your app. By the end, a test user has added a card in the simulator, 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 React Native, use the [React Native quickstart](/get-started/quickstarts/react-native-app) instead; [Choose a quickstart](/get-started/quickstarts) has the rest.

<BuildWithAI prompt={"Build the Agentcard integration for a native iOS app, following\nhttps://docs.agentcard.sh/get-started/quickstarts/ios-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.

- Xcode with a SwiftUI project, and access to the AgentcardWalletKit repository. The SDK is in early access, so the repository is private: ask us for an invite and we'll add your GitHub account.

<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="Present the wallet sheet">
    Add the package in Xcode and pin it at `0.1.0` or later:

    ```text Swift Package Manager theme={null}
    https://github.com/tiny-agent-company/agentcard-wallet-ios
    ```

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

    <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 sheet is about to open. For this walkthrough, paste the `url` into the view that presents the sheet:

    ```swift theme={null}
    import AgentcardWalletKit

    .sheet(isPresented: $showWallet) {
        AgentcardWalletSheet(link: walletLinkURL) { event in
            switch event {
            case .cardAttached(let cards):
                // the user added a card
            case .paymentCompleted(let merchant, let amountCents):
                // a payment went through
            case .tokenExpired:
                // fetch a fresh wallet link from your server and present again
            default:
                break
            }
        }
    }
    ```

    `AgentcardWalletSheet` also accepts `linkToken:` if you'd rather pass the raw token than the URL. Run the app in the simulator and present the sheet. Add a card with the test number `4242 4242 4242 4242`, any future expiry, any CVC. The handler receives `.cardAttached` with the card summary, and the `connected_card.updated` webhook goes out. Treat the Swift event as a UI signal and the webhook as the record.

    The sheet handles its own failures and tells the user what to do, so there's one case your code owns: `.tokenExpired`, which fires when the link or its session dies. Create a fresh wallet link and present the sheet again.

    The same sheet can open onto a pay sheet instead of the wallet home, by passing a pay intent: `AgentcardWalletSheet(link: walletLinkURL, pay: AgentcardPayIntent(merchant: "Wandy's", amountCents: 1275, reference: order.id))`. That's a different job: the user approving one specific charge your app already priced, with a card from their wallet, and `reference` is your stable id for that payment so a retry can't charge twice. 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 in a sheet 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="iOS (Swift)" href="/platforms/ios">
    The full package surface, the pay intent, expiry handling, 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>
