Skip to main content
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.
No per-user tokens. A client-credentials company token calls POST /buy directly as long as it passes user_id, so there is no token store and no refresh loop to build. The exchange flow in the iMessage guides is one option, not a requirement.

1. Mint the company token

Cache it in module scope: it lasts an hour, and a warm instance should not mint one per request.
supabase/functions/agentcard/index.ts
Agentcard answers with the token and the hour it lasts:
Set the secrets with the CLI, never as VITE_ variables:

2. Store a card

Create a vault session in the function and return only its url. Open that url from the click itself.
A created session carries the id you store and the url you open:
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.
src/components/AddCard.tsx
Close the tab and tell the user when the call fails, or they sit in front of a blank tab with nothing to read.
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.
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:
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:
For production, deploy a second function for webhooks 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 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.
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:
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} 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.
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.
A confirm of a cart shown that early is refused:
Ask for the cart again with delivery_address on that call, then confirm the hash that call returns:
A confirm pauses with decline_code: "vault_approval_required" and an approval_url. Nothing is charged while the confirm waits:
Open approval_url in a new tab. After the user approves with their passkey, send the same confirm again:
The confirm you send after the approval places the order:

Read a refusal

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.
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, any future expiry, any CVC. Next: Completing a purchase.