OpheliaDocs
Search docs⌘K
DashboardCreate an account
Payments

AgentCard

Pay for a booking with a card the end user keeps in their AgentCard vault, approved with their passkey.

Where it works

AgentCard works on Resy and OpenTable, on v1, with every v1 key, live or test.

ProviderStatusNotes
Resy
Supported
The card is saved on file to hold the table; nothing is charged. The SMS code comes before the payment approval.
OpenTable
Supported
The venue holds the card at seatings that require one. The payment approval comes before the SMS code.

When a venue cannot be paid through AgentCard, creating the booking returns HTTP 409 vault_unavailable and nothing is created; offer the end user a different venue or time.

Before you start

You need all of these before your first booking:

  • An Ophelia v1 API key.
  • An AgentCard organization token. Exchange your organization's client_id and client_secret for a client-credentials token (AgentCard: Create an access token). AgentCard API keys are rejected.
  • An AgentCard user with a stored card. The user must be connected to your AgentCard organization (AgentCard: Adding a card, Check for stored cards).
  • The end user's real details. Name, email, and a mobile number they can read right now: the venue texts a code to it.
  • A way to reach the end user within minutes, for the SMS code and the approval link.

Integrate

Every sample is marked Ophelia or AgentCard by the service it calls.

1.Search availability

Pass check_card_required: true to see whether the venue needs a card. If a slot needs no card, the booking may confirm without ever pausing for payment, so handle confirmed at any step.

curl
# Ophelia
curl -X POST "https://api.opheliaos.com/v1/availability/search" \
-H "Authorization: Bearer YOUR_LIVE_KEY" \
-H "Content-Type: application/json" \
-d '{
"venue_id": "ven_...",
"datetime": "2026-11-14T19:00:00-08:00",
"party_size": 2,
"window_minutes": 120,
"check_card_required": true
}'
typescript
// Ophelia
const res = await fetch('https://api.opheliaos.com/v1/availability/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPHELIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
venue_id: 'ven_...',
datetime: '2026-11-14T19:00:00-08:00',
party_size: 2,
window_minutes: 120,
check_card_required: true,
}),
});
const availability = await res.json(); // availability.card_required, availability.slots
Response
json
{
"venue_id": "ven_...",
"card_required": true,
"slots": [
{
"availability_id": "avail_...",
"kind": "time",
"venue_id": "ven_...",
"datetime": "2026-11-14T19:00:00-08:00",
"datetime_utc": "2026-10-02T02:00:00Z",
"display_time": "7:00 PM",
"party_size": 2,
"status": "available",
"price_required": false,
"deposit_required": false
}
]
}

2.Create the booking

Send "payment_collection": "agentcard_external" and no card fields. The optional metadata.billing_postal_code, billing_address_line1, billing_city, billing_state and billing_country are the billing details the processor's address check sees, so send the end user's real ones; without them the check sees postal code 94103 and country US. end_user is optional. Spell the mode exactly agentcard_external: an unrecognized value is not rejected, it is treated as a booking with no card and fails at the venue's card step.

curl
# Ophelia
# customer.phone_number must be the end user's real mobile: the venue texts the code there.
curl -X POST "https://api.opheliaos.com/v1/bookings" \
-H "Authorization: Bearer YOUR_LIVE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: YOUR_UUID" \
-d '{
"vertical": "dining",
"venue_id": "ven_...",
"availability_id": "avail_...",
"datetime": "2026-11-14T19:00:00-08:00",
"party_size": 2,
"customer": {
"name": "First Last",
"email": "jane.smith.diner@gmail.com",
"phone_number": "+1XXXXXXXXXX"
},
"payment_collection": "agentcard_external",
"metadata": {
"billing_address_line1": "1 Example St",
"billing_city": "New York",
"billing_state": "NY",
"billing_postal_code": "10001",
"billing_country": "US"
}
}'
typescript
// Ophelia
const res = await fetch('https://api.opheliaos.com/v1/bookings', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPHELIA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
vertical: 'dining',
venue_id: 'ven_...',
availability_id: 'avail_...',
datetime: '2026-11-14T19:00:00-08:00',
party_size: 2,
customer: {
name: 'First Last',
email: 'jane.smith.diner@gmail.com',
phone_number: '+1XXXXXXXXXX', // must be the end user's real mobile
},
payment_collection: 'agentcard_external',
metadata: { billing_postal_code: '10001', billing_country: 'US' },
}),
});
const booking = await res.json(); // 202 while processing, 200 otherwise: always read booking.status

The call can wait up to 150 seconds for the booking to move. It returns HTTP 202 while the booking is still processing, and HTTP 200 otherwise. A 200 means "not processing", never "confirmed". Always read status. The booking often comes back as processing; read it every few seconds until it changes. On Resy it reads requires_action for the SMS code within about a minute:

curl
# Ophelia
curl "https://api.opheliaos.com/v1/bookings/bkg_..." \
-H "Authorization: Bearer YOUR_LIVE_KEY"
json
{
"id": "bkg_...",
"object": "booking",
"status": "requires_action",
"provider": "resy",
"next_action": {
"type": "otp",
"expires_at": "2026-09-28T17:03:00Z",
"requirements": [
{ "field": "otp_code", "label": "Verification Code", "type": "string", "required": true }
]
}
}

The order differs by provider: on Resy the SMS code comes first, on OpenTable the payment approval comes first. The code step can also be skipped. Branch on next_action.type every time.

3.Relay the SMS code

When next_action.type is otp, send the code the end user received. Relay it immediately and treat next_action.expires_at as the upper bound. It is currently 5 minutes from the pause, but never hard-code it; always read expires_at.

curl
# Ophelia
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_.../continue" \
-H "Authorization: Bearer YOUR_LIVE_KEY" \
-H "Content-Type: application/json" \
-d '{ "otp_code": "123456" }'
typescript
// Ophelia
const res = await fetch(`https://api.opheliaos.com/v1/bookings/${booking.id}/continue`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPHELIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ otp_code: code }),
});
booking = await res.json(); // usually returns the payment pause

4.Create the AgentCard authorization

When next_action.type is payment_authorization, the booking is paused at the venue's card step. On Resy this comes about a minute after the SMS code. next_action.message sums up what to do: send agentcard_request with your user to AgentCard, get the end user's approval, and send the result back before expires_at.

json
{
"id": "bkg_...",
"object": "booking",
"status": "requires_action",
"provider": "resy",
"next_action": {
"type": "payment_authorization",
"message": "...",
"expires_at": "2026-09-28T17:17:00Z",
"agentcard_request": {
"merchant": "Venue Name",
"checkout_origin": "https://resy.com",
"psp": "stripe",
"mode": "token",
"request": {
"url": "https://api.stripe.com/v1/setup_intents/seti_.../confirm",
"method": "POST",
"headers": { "content-type": "application/x-www-form-urlencoded" },
"body": "..."
}
},
"requirements": [
{ "field": "psp_response", "label": "...", "type": "object", "required": false },
{ "field": "psp_abort", "label": "...", "type": "boolean", "required": false },
{ "field": "decline_code", "label": "...", "type": "string", "required": false }
]
}
}
agentcard_request keyWhat it is
merchant
The venue's name.
checkout_origin
The origin of the venue's checkout page, for example https://resy.com. Present when known.
psp
The venue's payment processor, for example "stripe".
mode
How AgentCard completes the payment, for example "token".
request.url
The processor endpoint the venue's checkout called.
request.method
Its HTTP method.
request.headers
The headers AgentCard needs to complete it.
request.body
The request body. AgentCard puts the end user's real card in on their device.
amount, currency
Present only when set. A Resy card hold has no amount because nothing is charged.
user (you add)
Your AgentCard user id for this end user (usr_...). The only key you add.

Copy every key unchanged, add user, and POST it to AgentCard's /api/v2/checkout/authorizations with your organization token. Deliver the returned approvalUrl to the end user (AgentCard: Choose a platform).

curl
# AgentCard: exchange client credentials for an org token
curl -X POST "https://api.agentcard.sh/api/v2/oauth/token" \
-d grant_type=client_credentials \
-d client_id=YOUR_AGENTCARD_CLIENT_ID \
-d client_secret=YOUR_AGENTCARD_CLIENT_SECRET
 
# AgentCard: create the authorization (agentcard_request plus user)
curl -X POST "https://api.agentcard.sh/api/v2/checkout/authorizations" \
-H "Authorization: Bearer YOUR_AGENTCARD_ORG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"user": "usr_...",
"merchant": "Venue Name",
"checkout_origin": "https://resy.com",
"psp": "stripe",
"mode": "token",
"request": {
"url": "https://api.stripe.com/v1/setup_intents/seti_.../confirm",
"method": "POST",
"headers": { "content-type": "application/x-www-form-urlencoded" },
"body": "..."
}
}'
typescript
// AgentCard
const created = await fetch('https://api.agentcard.sh/api/v2/checkout/authorizations', {
method: 'POST',
headers: {
Authorization: `Bearer ${agentcardOrgToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...booking.next_action.agentcard_request, user: 'usr_...' }),
});
const authorization = await created.json();
await sendApprovalLink(authorization.approvalUrl); // your channel to the end user

5.Wait for approval, then hand back

Read the AgentCard authorization until its status is approved. awaiting_approval and submitted_on_device mean keep waiting; declined, expired and cancelled mean abandon (step 6). Then send its response object to Ophelia unchanged as {"psp_response": {"status", "headers", "body"}}. Send only response, not the whole authorization object.

curl
# AgentCard: read the authorization
curl "https://api.agentcard.sh/api/v2/checkout/authorizations/cauth_..." \
-H "Authorization: Bearer YOUR_AGENTCARD_ORG_TOKEN"
 
# Ophelia: forward its response object unchanged
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_.../continue" \
-H "Authorization: Bearer YOUR_LIVE_KEY" \
-H "Content-Type: application/json" \
-d '{
"psp_response": {
"status": 200,
"headers": { "content-type": "application/json" },
"body": "{...}"
}
}'
typescript
// AgentCard
const current = await (await fetch(
`https://api.agentcard.sh/api/v2/checkout/authorizations/${authorization.id}`,
{ headers: { Authorization: `Bearer ${agentcardOrgToken}` } },
)).json();
 
if (current.status === 'approved') {
// Ophelia
const res = await fetch(`https://api.opheliaos.com/v1/bookings/${booking.id}/continue`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPHELIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ psp_response: current.response }),
});
booking = await res.json(); // read status: confirmed, failed, or 202 still processing
}

This call also waits up to 150 seconds, so it usually returns confirmed, with the venue's confirmation code, or failed. On Resy that takes about ten seconds. If it returns 202, read GET /v1/bookings/{booking_id} until the booking is confirmed or failed. If psp_response is malformed, you get HTTP 400 and the booking stays paused; fix the body and retry before expires_at. An answer after expires_at also gets HTTP 400, but the booking is failed: payment_outcome_unknown for a late psp_response (the card may have reached the venue), payment_approval_expired for a late psp_abort. A 400 carries only an error message, so read GET /v1/bookings/{booking_id} to tell the two apart.

Warning: stop at Ophelia's deadline, not AgentCard's. AgentCard's approval window starts when you create the authorization, which is after Ophelia's pause, so it outlives Ophelia's expires_at. When Ophelia's expires_at passes, stop polling, cancel the AgentCard authorization (AgentCard: Cancel a checkout authorization) so the end user cannot approve a payment that can no longer complete, and tell them to start again. Leave a margin of about 30 seconds before expires_at for your own /continue call.

6.Abandon a payment

If the end user declines, or the AgentCard authorization expires or is cancelled, abandon the payment. decline_code is an optional reason for your own records; it is not returned on the booking. The booking fails with error_code: "payment_declined".

curl
# Ophelia
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_.../continue" \
-H "Authorization: Bearer YOUR_LIVE_KEY" \
-H "Content-Type: application/json" \
-d '{ "psp_abort": true, "decline_code": "user_declined" }'
typescript
// Ophelia
await fetch(`https://api.opheliaos.com/v1/bookings/${booking.id}/continue`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPHELIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ psp_abort: true, decline_code: 'user_declined' }),
});

Cancelling the Ophelia booking does not cancel the AgentCard authorization. Cancel it on AgentCard yourself.

Webhook-driven variant

You don't have to poll Ophelia. The booking.requires_action webhook carries the full next_action, including agentcard_request, and booking.confirmed, booking.failed or booking.cancelled tells you how it ended. The webhook's booking is a summary (id, status, provider, error_code, error_message, next_action) without the confirmation code, so read GET /v1/bookings/{booking_id} after booking.confirmed. Each delivery is signed in the Ophelia-Signature header. You can also have AgentCard notify you instead of polling it (AgentCard: checkout authorization events).

typescript
// Ophelia webhook handler (verify the Ophelia-Signature header first)
async function onOpheliaEvent(event: { type: string; data: { object: Booking } }) {
const booking = event.data.object;
if (event.type === 'booking.requires_action') {
if (booking.next_action?.type === 'otp') await askEndUserForCode(booking);
if (booking.next_action?.type === 'payment_authorization') await startAgentCardAuthorization(booking);
}
if (['booking.confirmed', 'booking.failed', 'booking.cancelled'].includes(event.type)) {
// The webhook carries a summary; read the full booking for the confirmation code.
await report((await ophelia(`/v1/bookings/${booking.id}`)).body);
}
}

Full example

One server-side function that runs the whole loop: create, relay the SMS code through getOtp(), deliver the approval link through sendApprovalLink(url), wait on AgentCard, continue, and handle every terminal status. It enforces expires_at locally.

typescript
// Server side only. Books a venue and pays with the end user's AgentCard vault card.
// Secrets come from the environment: OPHELIA_API_KEY, AGENTCARD_CLIENT_ID, AGENTCARD_CLIENT_SECRET.
const OPHELIA = 'https://api.opheliaos.com';
const AGENTCARD = 'https://api.agentcard.sh';
const MARGIN_MS = 30_000; // finish your own /continue call this long before expires_at
 
type Booking = {
id: string;
status: 'processing' | 'requires_action' | 'confirmed' | 'failed' | 'cancelled' | 'expired';
next_action?: {
type: string;
expires_at: string;
agentcard_request?: Record<string, unknown>;
};
error_code?: string;
error_message?: string;
provider_booking_id?: string;
confirmation?: { confirmation_code?: string };
};
 
type Hooks = {
getOtp: () => Promise<string>; // ask the end user for the SMS code
sendApprovalLink: (url: string) => Promise<void>; // show or text the approval link
};
 
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
 
// Ophelia
async function ophelia(path: string, init: RequestInit = {}) {
const res = await fetch(`${OPHELIA}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.OPHELIA_API_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
return { http: res.status, body: await res.json() };
}
 
// AgentCard: exchange client credentials for an org token (API keys are rejected)
async function agentcardToken(): Promise<string> {
const res = await fetch(`${AGENTCARD}/api/v2/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.AGENTCARD_CLIENT_ID!,
client_secret: process.env.AGENTCARD_CLIENT_SECRET!,
}),
});
if (!res.ok) throw new Error(`AgentCard token: HTTP ${res.status}`);
return (await res.json()).access_token;
}
 
// Ophelia: read the result of a /continue call
async function afterContinue(bookingId: string, answer: { http: number; body: any }): Promise<Booking> {
if (answer.http >= 400) {
// A 400 has only an error message. Read the booking to see which case it was:
// an answer after expires_at fails the booking; a malformed body leaves it paused.
const current: Booking = (await ophelia(`/v1/bookings/${bookingId}`)).body;
if (current.status === 'failed') return current;
throw new Error(`continue: HTTP ${answer.http} ${answer.body.error}`);
}
return answer.body; // 200 or 202: read status, a 200 is not a confirmation
}
 
// Ophelia: abandon the payment step
async function pspAbort(booking: Booking, reason: string): Promise<Booking> {
return afterContinue(booking.id, await ophelia(`/v1/bookings/${booking.id}/continue`, {
method: 'POST',
body: JSON.stringify({ psp_abort: true, decline_code: reason }),
}));
}
 
async function handleOtp(booking: Booking, hooks: Hooks): Promise<Booking> {
const deadline = Date.parse(booking.next_action!.expires_at) - MARGIN_MS;
const code = await Promise.race([hooks.getOtp(), sleep(deadline - Date.now()).then(() => null)]);
if (code === null) {
// The code window closed. Cancel this booking and start a new one.
return (await ophelia(`/v1/bookings/${booking.id}/cancel`, { method: 'POST' })).body;
}
return afterContinue(booking.id, await ophelia(`/v1/bookings/${booking.id}/continue`, {
method: 'POST',
body: JSON.stringify({ otp_code: code }),
}));
}
 
async function handlePayment(booking: Booking, agentcardUser: string, hooks: Hooks): Promise<Booking> {
const action = booking.next_action!;
const deadline = Date.parse(action.expires_at) - MARGIN_MS; // Ophelia's deadline binds, not AgentCard's
const token = await agentcardToken();
const auth = { Authorization: `Bearer ${token}` };
 
// AgentCard: create the authorization from agentcard_request plus your user id
const created = await fetch(`${AGENTCARD}/api/v2/checkout/authorizations`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...action.agentcard_request, user: agentcardUser }),
});
if (!created.ok) {
// For example, one of your AgentCard rules refused the purchase.
return pspAbort(booking, `agentcard_create_${created.status}`);
}
const authorization = await created.json();
await hooks.sendApprovalLink(authorization.approvalUrl);
 
while (Date.now() < deadline) {
// AgentCard: read the authorization
const res = await fetch(`${AGENTCARD}/api/v2/checkout/authorizations/${authorization.id}`, { headers: auth });
const current = await res.json();
 
if (current.status === 'approved') {
// Ophelia: forward the response object unchanged, not the whole authorization
return afterContinue(booking.id, await ophelia(`/v1/bookings/${booking.id}/continue`, {
method: 'POST',
body: JSON.stringify({ psp_response: current.response }),
}));
}
if (['declined', 'expired', 'cancelled'].includes(current.status)) {
return pspAbort(booking, current.status === 'declined' ? 'user_declined' : `agentcard_${current.status}`);
}
await sleep(2000); // awaiting_approval or submitted_on_device: keep waiting
}
 
// Ophelia's window is about to close. Stop the end user from approving a payment
// that can no longer complete, then tell them to start again.
await fetch(`${AGENTCARD}/api/v2/checkout/authorizations/${authorization.id}/cancel`, {
method: 'POST',
headers: auth,
});
return pspAbort(booking, 'approval_timeout');
}
 
export async function bookWithAgentCard(
input: {
venueId: string;
availabilityId: string;
datetime: string;
partySize: number;
customer: { name: string; email: string; phone_number: string }; // the end user's real mobile
agentcardUser: string; // usr_... connected to your AgentCard org
billing?: Record<string, string>; // billing_postal_code, billing_country, ...
},
hooks: Hooks,
): Promise<Booking> {
// Ophelia: create the booking
const created = await ophelia('/v1/bookings', {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({
vertical: 'dining',
venue_id: input.venueId,
availability_id: input.availabilityId,
datetime: input.datetime,
party_size: input.partySize,
customer: input.customer,
payment_collection: 'agentcard_external',
metadata: input.billing ?? {},
}),
});
if (created.http >= 400) {
// 409 vault_unavailable: this venue cannot be paid through AgentCard.
// 503 agentcard_disabled, and the rest: see Errors.
throw new Error(`create: HTTP ${created.http} ${created.body.error_code ?? created.body.error}`);
}
let booking: Booking = created.body;
 
for (;;) {
switch (booking.status) {
case 'confirmed':
case 'failed':
case 'cancelled':
case 'expired':
return booking; // terminal: see nextStep() below
case 'processing':
await sleep(2000);
booking = (await ophelia(`/v1/bookings/${booking.id}`)).body;
break;
case 'requires_action':
if (booking.next_action?.type === 'otp') booking = await handleOtp(booking, hooks);
else if (booking.next_action?.type === 'payment_authorization') {
booking = await handlePayment(booking, input.agentcardUser, hooks);
} else throw new Error(`unhandled next_action.type: ${booking.next_action?.type}`);
break;
}
}
}
 
// What to tell the end user, and whether it is safe to book again.
export function nextStep(booking: Booking): string {
if (booking.status === 'confirmed') {
return `Booked. Confirmation ${booking.confirmation?.confirmation_code}.`;
}
if (booking.status === 'cancelled' || booking.status === 'expired') {
return 'Not booked. Start a new booking.';
}
switch (booking.error_code) {
case 'payment_declined':
return 'The payment was not approved. Start a new booking if the end user wants to.';
case 'card_declined':
return 'The card was declined. Try again with another card.';
case 'payment_approval_expired':
return 'The payment window closed. Start a new booking.';
case 'payment_outcome_unknown':
case 'payment_replay_failed':
return 'The card may have reached the venue. Check with the venue before booking again.';
case 'payment_setup_failed':
return 'This venue could not be paid through AgentCard.';
default:
return `Not booked: ${booking.error_message ?? booking.error_code}.`;
}
}

Timing

WindowHow longWhat to do
SMS code
Until the otp action's next_action.expires_at. Currently 5 minutes from the pause.
Relay the code immediately. Never hard-code the window.
Payment
15 minutes from the pause, at next_action.expires_at.
Binding. Send your answer at least 30 seconds before it.
AgentCard approval
15 minutes, on AgentCard's side, from when you create the authorization.
Starts later, so it outlives Ophelia's window. Do not rely on it.
Typical run
About 6 minutes from create to confirmed.
Most of it is the end user reading the code and approving.

An answer that arrives after expires_at will not complete the reservation. Start a new booking.

Errors

What happenedWhere it showsWhat to do
vault_unavailable: this venue cannot be paid through AgentCard.
HTTP 409 at create. Nothing is created.
Offer a different venue or time.
agentcard_disabled: AgentCard payments are off.
HTTP 503 at create.
Contact support.
Malformed psp_response.
HTTP 400 on /continue. The booking stays paused.
Send the authorization's response object, fix the body, and retry before expires_at.
Late answer: your /continue arrived after expires_at.
HTTP 400 on /continue, with only an error message. The booking is failed: payment_outcome_unknown after a late psp_response, payment_approval_expired after a late psp_abort.
Read GET /v1/bookings/{booking_id} for the outcome. After payment_outcome_unknown the card may have reached the venue; check before booking again.
payment_declined: you abandoned the payment.
Booking error_code.
Start a new booking if the end user wants to.
payment_approval_expired: no answer before expires_at.
Booking error_code.
Start a new booking. If the end user had approved, check with the venue first.
payment_outcome_unknown: Ophelia cannot tell whether the payment step finished.
Booking error_code.
The card may have reached the venue; check before booking again.
payment_setup_failed: Ophelia could not prepare the payment for this venue.
Booking error_code.
Offer a different venue or time. Contact support if it repeats.
payment_replay_failed: the processor's answer could not be applied to the checkout.
Booking error_code.
The processor may hold the card; check before booking again.
card_declined: the processor declined the end user's card.
Booking error_code.
Tell the end user. Start a new booking with another card.

See Errors for every code.

Testing

Ophelia has no sandbox for AgentCard payments. Every booking is a real reservation, including on test keys, which use the 3-booking free allowance. AgentCard sandbox cards cannot pay a live venue, so an end-to-end test needs AgentCard production credentials and a real card. The whole loop takes about six minutes:

  1. Pick a slot. Search availability on a Resy or OpenTable venue that holds a card, a few days out, with party_size: 2. The response says card_required: true.
  2. Book. Create the booking with that slot, your real name, email and a phone you can read, and "payment_collection": "agentcard_external". Send no card fields.
  3. Relay the code. When the booking reads next_action.type: "otp", post the code you received as {"otp_code": "..."}.
  4. Authorize. When it reads next_action.type: "payment_authorization", add user to next_action.agentcard_request, create the AgentCard authorization, open its approvalUrl, and approve.
  5. Hand it back. Read the authorization until approved, then post its response as psp_response. The booking reads confirmed with the venue's confirmation code.
  6. Cancel. Call POST /v1/bookings/{booking_id}/cancel right away. It is a real reservation, and the venue's no-show terms apply.

Approve within next_action.expires_at, 15 minutes from the payment pause. To rehearse only the AgentCard half (vault, authorization, approval), use AgentCard's sandbox and shop.agentcard.sh.

Security

The card number and CVV never reach Ophelia or your servers. Your AgentCard credentials never reach Ophelia. Ophelia sees the processor's response and the billing details you send. The billing details are stored with the booking; the processor's response is stored with the booking attempt, encrypted.

Your server holds:

  • Your AgentCard organization's client_id, client_secret and org token. Keep them server side.
  • The AgentCard user_id of each end user.