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

# Backend or CLI agent, no frontend

> Sandbox walkthrough for a backend or CLI agent: connect a user, hand them a hosted wallet link, add test card 4242, and call POST /buy from your server 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 product with no frontend: a CLI, a backend agent, an email flow, anything that can deliver a URL and make HTTP calls. You create a wallet link and hand the URL to the user however you like; we host the page it opens. Then your server calls `POST /buy` itself and gets the cart back as data, with a hash it echoes to confirm. By the end, a test user has added a card on the hosted wallet, and your server 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 you do have a frontend, the [web app quickstart](/get-started/quickstart) is the default; [Choose a quickstart](/get-started/quickstarts) has the rest.

<BuildWithAI prompt={"Build the Agentcard integration for a backend or CLI agent with no frontend, following\nhttps://docs.agentcard.sh/get-started/quickstarts/backend-agent 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.

<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="Hand the user a hosted wallet link">
    The wallet for this shape is a hosted page we run, reached through a link your server creates:

    <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
    }
    ```

    Deliver the `url` any way your product can: print it in the terminal, put it in an email, drop it in a chat. For this walkthrough, open it in your browser. The user gets the full wallet: add a card, see their cards, approve a payment. Add a card with the test number `4242 4242 4242 4242`, any future expiry, any CVC.

    Two webhooks go out here: `wallet_link.opened` the first time the link is opened, and `connected_card.updated` when the card is added. Don't poll the link for status; the webhooks are the record of what actually happened.

    A link belongs to one user, expires after 15 minutes by default (`expires_in` accepts 60 to 86400 seconds), and can be opened up to 20 times within that window, so the user can open it again without asking for a new one. Link previews and unfurl bots never consume an open, because the page only exchanges the link when a real browser loads it. When the user needs the wallet again later, create a fresh link; they're cheap and there's no limit on how many you create.

    The same link can also open straight onto a pay sheet for one specific charge, by appending `merchant` (a display name) and `amount` (in cents) to the URL. That's the user approving a charge your product already priced, with a card from their wallet. What comes next is different: your server asking the agent to go buy something at a merchant, which is `buy`.
  </Step>

  <Step title="Let the agent buy">
    With no frontend, your server is the agent's hands: it calls `POST /buy` directly with the user's connection token from step 2, shows the `reply` wherever your product talks to the user, and confirms with the cart's hash.

    Your server sends what the user wants as plain text. The bearer is the user's token from step 2, so everything that follows runs as that user: the agent searches the merchant, asks follow-up questions, and stops each turn at `needs_input` until it has enough to build a cart.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/buy \
        -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"ask": "a phone charger from Amazon, ship it to 548 Market St, San Francisco, CA 94104"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/buy", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${USER_ACCESS_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ ask: "a phone charger from Amazon, ship it to 548 Market St, San Francisco, CA 94104" }),
      });
      const turn = await res.json();
      ```

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

      res = requests.post(
          "https://api.agentcard.sh/buy",
          headers={"Authorization": f"Bearer {USER_ACCESS_TOKEN}"},
          json={"ask": "a phone charger from Amazon, ship it to 548 Market St, San Francisco, CA 94104"},
      )
      turn = res.json()
      ```
    </CodeGroup>

    Give the client a timeout of at least 120 seconds: each turn runs against a live merchant. The first turn usually asks a question. Yours will differ, because sandbox conversations are live, not scripted:

    ```json Output theme={null}
    {
      "conversation_id": "cmemw5k2p00b7",
      "status": "needs_input",
      "reply": "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?",
      "cart": null,
      ...
    }
    ```

    `needs_input` is not an error; it's how a purchase progresses. Show the `reply` to the user and send their answer back as the next `ask` on the same `conversation_id`:

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/buy \
        -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"conversation_id": "CONVERSATION_ID", "ask": "the first one is fine"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/buy", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${USER_ACCESS_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ conversation_id: turn.conversation_id, ask: "the first one is fine" }),
      });
      const next = await res.json();
      ```

      ```python Python theme={null}
      res = requests.post(
          "https://api.agentcard.sh/buy",
          headers={"Authorization": f"Bearer {USER_ACCESS_TOKEN}"},
          json={"conversation_id": turn["conversation_id"], "ask": "the first one is fine"},
      )
      turn = res.json()
      ```
    </CodeGroup>

    Keep answering until the response carries a `cart` with a `totalCents` and a `hash`. That's the agent showing the exact all-in price before anything happens:

    ```json Output theme={null}
    {
      "conversation_id": "cmemw5k2p00b7",
      "status": "needs_input",
      "reply": "Anker 30W USB-C charger, $15.99. With the $0.40 service fee the total is $16.39, shipping to 548 Market St. Want me to place it?",
      "cart": {
        "merchant": "retail",
        "merchant_name": "Amazon",
        "items": [
          { "name": "Anker 30W USB-C Charger", "qty": 1, "priceCents": 1599 }
        ],
        "serviceFeesCents": 40,
        "tipCents": 0,
        "totalCents": 1639,
        "hash": "c47a91e02b3d5f68"
      },
      "error_code": null
    }
    ```

    Confirm by echoing the cart's `hash`, not the word "yes". The hash binds the approval to exactly this cart at exactly this price: if anything drifted since the cart was shown, the confirm returns a `409` with the fresh carts instead of charging something the user never saw.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://api.agentcard.sh/buy \
        -H "Authorization: Bearer $USER_ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        -d '{"conversation_id": "CONVERSATION_ID", "confirm": "HASH_FROM_THE_CART"}'
      ```

      ```javascript Node theme={null}
      const res = await fetch("https://api.agentcard.sh/buy", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${USER_ACCESS_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ conversation_id: turn.conversation_id, confirm: turn.cart.hash }),
      });
      const receipt = await res.json();
      ```

      ```python Python theme={null}
      res = requests.post(
          "https://api.agentcard.sh/buy",
          headers={"Authorization": f"Bearer {USER_ACCESS_TOKEN}"},
          json={"conversation_id": turn["conversation_id"], "confirm": turn["cart"]["hash"]},
      )
      receipt = res.json()
      ```
    </CodeGroup>

    The hash verifies, the confirm gate passes, and then sandbox refuses the one thing it exists to refuse. Before any money is reserved or any card is created, the checkout denies with the sandbox wall:

    ```json Output theme={null}
    {
      "conversation_id": "cmemw5k2p00b7",
      "status": "declined",
      "reply": "This connection is in sandbox mode, and sandbox test cards cannot pay real merchants, so a live Amazon order can't be placed. To place real orders, connect with a production-mode client.",
      "cart": null,
      ...
    }
    ```

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

    Branch on `status`, never on the `reply` text: the prose varies run to run, the status and the cart don't. The [Purchase API](/purchase/purchase-api) page has the full envelope, multi-merchant confirms, and the rules a production integration needs.
  </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, handed them a wallet, they added a card, and your server 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="Hosted link" href="/platforms/hosted-link">
    Link semantics in full, delivery options, and the rules for your agent.
  </Card>

  <Card title="Purchase API" href="/purchase/purchase-api">
    The full envelope, multi-merchant confirms, how the money moves, and the rules a production caller needs.
  </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>
