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

# Add the Vault to a Lovable app

> One Supabase edge function, two columns per user, and your Lovable app pays with the user's own card.

Your Lovable app pays with the user's own card, and nothing sensitive passes
through your page. A Lovable app is a React front end with Supabase behind it,
which is all the Vault and the Purchase API need: the company credential lives
in a Supabase edge-function secret, and the user's card lives in the Vault.

The whole integration is one edge function and two columns:

* `AGENTCARD_CLIENT_ID` and `AGENTCARD_CLIENT_SECRET` as Supabase secrets.
* `profiles.agentcard_user_id`, one text column holding each person's Agentcard
  user id.
* `profiles.vault_session_id`, one text column holding the vault session that
  person is in the middle of. Add both columns before you deploy the function;
  step 2 writes this one and reads it back.

<Note>
  No per-user tokens. A client-credentials company token calls [`POST
    /buy`](/api-reference/purchases/buy) directly as long as it passes `user_id`, so
  there is no token store and no refresh loop to build. The
  [exchange](/guides/integrate-the-purchase-api-into-an-imessage-agent) flow in the
  iMessage guides is one option, not a requirement.
</Note>

## 1. Mint the company token

Cache it in module scope: it lasts an hour, and a warm instance should not mint
one per request.

```ts supabase/functions/agentcard/index.ts theme={null}
let cached: { token: string; expiresAt: number } | null = null;

async function orgToken(): Promise<string> {
  if (cached && Date.now() < cached.expiresAt) return cached.token;

  const res = await fetch("https://api.agentcard.sh/api/v2/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: Deno.env.get("AGENTCARD_CLIENT_ID")!,
      client_secret: Deno.env.get("AGENTCARD_CLIENT_SECRET")!,
    }),
  });

  if (!res.ok) throw new Error(`agentcard token ${res.status}: ${await res.text()}`);

  const { access_token, expires_in } = await res.json();
  cached = { token: access_token, expiresAt: Date.now() + (expires_in - 60) * 1000 };
  return access_token;
}
```

Agentcard answers with the token and the hour it lasts:

```json theme={null}
{
  "access_token": "eyJhbGciOiJIUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "api"
}
```

Set the secrets with the CLI, never as `VITE_` variables:

```bash theme={null}
supabase secrets set AGENTCARD_CLIENT_ID=… AGENTCARD_CLIENT_SECRET=…
supabase functions deploy agentcard
```

## 2. Store a card

Create a vault session in the function and return only its `url`. Open that
`url` from the click itself.

```ts theme={null}
const res = await fetch("https://api.agentcard.sh/api/v2/vault_sessions", {
  method: "POST",
  headers: { Authorization: `Bearer ${await orgToken()}`, "Content-Type": "application/json" },
  body: "{}",
});
const session = await res.json();
if (!res.ok || !session.url) {
  return Response.json({ error: session.error ?? "vault session refused" }, { status: 502 });
}
await db.from("profiles").update({ vault_session_id: session.id }).eq("id", userId);
return Response.json({ url: session.url });
```

A created session carries the `id` you store and the `url` you open:

```json theme={null}
{
  "object": "vault_session",
  "id": "vs_2q9d1x8f3k2m4t7w",
  "user_id": null,
  "url": "https://vault.agentcard.sh/v?vs=vs_2q9d1x8f3k2m4t7w.3k1v…",
  "expires_at": "2026-08-28T21:00:00Z",
  "poll_interval": 3,
  "test_mode": false
}
```

Check the response before you save anything. A refused session carries no `url`,
and writing its missing `id` leaves the poll below with no session to read.

```tsx src/components/AddCard.tsx theme={null}
async function addCard() {
  const tab = window.open("", "_blank"); // on the click itself, before any await
  const { data, error } = await supabase.functions.invoke("agentcard", { body: { action: "connect" } });
  if (error || !data?.url) {
    tab?.close();
    setMessage("Adding a card did not start. Try again.");
    return;
  }
  if (tab) tab.location = data.url;
}
```

Close the tab and tell the user when the call fails, or they sit in front of a
blank tab with nothing to read.

<Warning>
  Open the tab on the click, before the `await`. A tab opened after a network call
  counts as a popup and Safari and Firefox block it. Never load the Vault in an
  iframe either: the page refuses to render inside another page's frame and the
  user sees a blank box.
</Warning>

When the user finishes, poll the session and store the `user_id` it reports.
Polling is enough to ship, and it needs no public endpoint:

```ts theme={null}
const s = await agentcard("GET", `/api/v2/vault_sessions/${profile.vault_session_id}`);
if (s.status === "linked") {
  await db.from("profiles").update({ agentcard_user_id: s.user_id }).eq("id", userId);
}
```

A finished session reports `linked` and the user id to store. A session still
waiting reports `pending`, and you read it again after `poll_interval` seconds:

```json theme={null}
{
  "object": "vault_session",
  "id": "vs_2q9d1x8f3k2m4t7w",
  "status": "linked",
  "user_id": "usr_8f3k2m",
  "linked_at": "2026-09-02T18:41:07Z",
  "poll_interval": 3,
  "test_mode": false
}
```

For production, deploy a second function for
[webhooks](/vault/completing-a-purchase) and subscribe `vault.session_linked`,
`vault.card_stored` and the `checkout_authorization.*` events. Deploy it with
`--no-verify-jwt`, or every delivery is a 401. Supabase then authenticates
nothing for you, so
[verify the `AgentCard-Signature` header](/webhooks/overview#verify-the-signature)
against the raw body and your endpoint secret, and drop any delivery that fails.

## 3. Buy

One action in the same function proxies a turn. `ask` searches or refines a
cart; `confirm` echoes a cart's `hash` and places it, and is the only call that
can move money.

```ts theme={null}
await fetch("https://api.agentcard.sh/buy", {
  method: "POST",
  headers: { Authorization: `Bearer ${await orgToken()}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    user_id: profile.agentcard_user_id,
    delivery_address: address,     // on every call, see below
    conversation_id: input.conversation_id,
    ...(input.confirm
      ? { confirm: input.confirm, payment_source: "vault" }
      : { ask: input.ask }),
  }),
  signal: AbortSignal.timeout(150_000),  // a turn runs against a live merchant
});
```

An `ask` comes back with the cart to show the user and the `hash` that confirms
it. Show the user `cart.items`, `cart.totalCents` and `cart.approvedCeilingCents`,
never the prose in `reply`:

```json theme={null}
{
  "conversation_id": "conv_9a8b7c6d5e4f3a2b1c0d9e8f",
  "status": "needs_input",
  "reply": "Found it. Cafe Mesa de los Santos Colombian Ground Coffee, 16 oz. Total $23.06 shipping to 1900 Jefferson St. Want me to place it?",
  "cart": {
    "merchant": "retail",
    "merchant_name": "Amazon",
    "items": [
      { "name": "Cafe Mesa de los Santos Colombian Ground Coffee, 16 oz", "qty": 1, "priceCents": 2250 }
    ],
    "totalCents": 2306,
    "approvedCeilingCents": 3444,
    "hash": "9f2c4a1b8e3d5f07"
  }
}
```

`approvedCeilingCents` is the most the confirm can authorize when the merchant
finalizes tax and shipping as it places the order. The field is `null` when the
merchant charges the total exactly. A user who sees only `totalCents` approves
less than the card can be charged, so show the ceiling whenever the cart carries
one.

The timeout ends your wait, not the turn. The merchant keeps working and can
still place the order, so read
[`GET /buy/conversations/{id}`](/api-reference/purchases/conversation) before you
send that confirm again: wait for `turn_in_progress` to clear, then read
`orders`. An order already there was placed, and a second confirm while the turn
runs is refused with `409 turn_in_progress`.

<Warning>
  Send `delivery_address` on the same call that asks for the cart, and on every
  call after it. A cart shown before an address was bound to the conversation
  cannot be confirmed against one. Collect the address when the user adds their
  card, not in the middle of the chat.
</Warning>

A confirm of a cart shown that early is refused:

```text theme={null}
{
  "error": "the cart was shown before a delivery address was bound to this conversation: send delivery_address on the same call that asks for the cart, then confirm the hash that call returns",
  "code": "delivery_address_bound_after_cart",
  "conversation_id": "conv_9a8b7c6d5e4f3a2b1c0d9e8f"
}
```

Ask for the cart again with `delivery_address` on that call, then confirm the
hash that call returns:

```bash theme={null}
curl -X POST https://api.agentcard.sh/buy \
  -H "Authorization: Bearer $ORG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "usr_8f3k2m",
    "ask": "a 16 oz bag of Colombian ground coffee from Amazon",
    "delivery_address": {
      "street": "1900 Jefferson St",
      "address2": "Apt 4",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94123",
      "phone": "+14155550100",
      "name": "Ada Lovelace"
    }
  }'
```

A confirm pauses with `decline_code: "vault_approval_required"` and an
`approval_url`. Nothing is charged while the confirm waits:

```json theme={null}
{
  "conversation_id": "conv_9a8b7c6d5e4f3a2b1c0d9e8f",
  "status": "declined",
  "decline_code": "vault_approval_required",
  "approval_url": "https://vault.agentcard.sh/authorize?id=cauth_2q9d1x8f3k2m4t7w",
  "charge_status": "none"
}
```

Open `approval_url` in a new tab. After the user approves with their passkey,
send the same confirm again:

```bash theme={null}
curl -X POST https://api.agentcard.sh/buy \
  -H "Authorization: Bearer $ORG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "usr_8f3k2m",
    "conversation_id": "conv_9a8b7c6d5e4f3a2b1c0d9e8f",
    "confirm": "9f2c4a1b8e3d5f07",
    "payment_source": "vault",
    "delivery_address": {
      "street": "1900 Jefferson St",
      "address2": "Apt 4",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94123",
      "phone": "+14155550100",
      "name": "Ada Lovelace"
    }
  }'
```

The confirm you send after the approval places the order:

```json theme={null}
{
  "conversation_id": "conv_9a8b7c6d5e4f3a2b1c0d9e8f",
  "status": "order_placed",
  "reply": "Order placed at Amazon. Charged $23.06 to your Visa ending 4832.",
  "order_id": "3f9a8c1b-7d2e-4c5a-9b1f-2e8d4a6c0b17",
  "payment_source": { "source": "vault", "brand": "visa", "last4": "4832" },
  "charge_status": "settled"
}
```

### Read a refusal

| Code | What it means | What to do |
| - | - | - |
| `delivery_address_bound_after_cart` | You sent your first `delivery_address` after the cart was shown. | Ask for the cart again with `delivery_address` on that call, then confirm the hash it returns. |
| `vault_approval_required` | The user has to approve this purchase with their passkey. | Open `approval_url` in a new tab, then send the same confirm again. |
| `sandbox_mode` | A sandbox credential reached the final charge. | Nothing. The run passed. |

## Keep the user id server-side

The browser sends its Supabase JWT and nothing else that identifies the payer.
The function calls `auth.getUser()`, looks up that caller's row, and spends only
that row's `agentcard_user_id`.

```ts theme={null}
const db = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
  global: { headers: { Authorization: req.headers.get("Authorization")! } },
});
const { data: auth } = await db.auth.getUser();
if (!auth?.user) return new Response("unauthorized", { status: 401 });
```

If the browser could pass a `user_id`, any visitor could spend any other user's
card. Enable anonymous sign-ins in Supabase Auth and the app still needs no login
screen.

## Test it

A sandbox credential runs the whole flow against the real merchant and declines
the final charge with `decline_code: "sandbox_mode"`. That decline is the pass
condition, not a failure. Store any of
[Stripe's published test cards](https://docs.stripe.com/testing), any future
expiry, any CVC.

Next: [Completing a purchase](/vault/completing-a-purchase).


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