AGENTCARD_CLIENT_IDandAGENTCARD_CLIENT_SECRETas 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.
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
VITE_ variables:
2. Store a card
Create a vault session in the function and return only itsurl. Open that
url from the click itself.
id you store and the url you open:
url,
and writing its missing id leaves the poll below with no session to read.
src/components/AddCard.tsx
user_id it reports.
Polling is enough to ship, and it needs no public endpoint:
linked and the user id to store. A session still
waiting reports pending, and you read it again after poll_interval seconds:
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.
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.
A confirm of a cart shown that early is refused:
delivery_address on that call, then confirm the
hash that call returns:
decline_code: "vault_approval_required" and an
approval_url. Nothing is charged while the confirm waits:
approval_url in a new tab. After the user approves with their passkey,
send the same confirm again:
Read a refusal
Keep the user id server-side
The browser sends its Supabase JWT and nothing else that identifies the payer. The function callsauth.getUser(), looks up that caller’s row, and spends only
that row’s agentcard_user_id.
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 withdecline_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.