Merchant commerce kit
Serve UCP and ACP checkout with the Verification Gateway SDK — holds, pending settlements, durable stores, AP2 mandates and the confirm → pending_merchant → reportSettlement flow. SDK 5.18.0.
The checkout kits in @astrasyncai/verification-gateway do the protocol work for a merchant that
sells to AI agents. You write your catalog, your charge and your fulfilment; the kit handles
negotiation, idempotency, signatures, AP2 mandates, owner approvals and reporting the settlement
back to AstraSync. AstraSync never moves the money: you charge with your own processor.
Everything marked SDK 5.18.0 on this page needs @astrasyncai/verification-gateway@5.18.0 or
later. The kits themselves shipped in 5.15.0.
The two kits
| Kit | Protocol | Routes it serves |
|---|---|---|
createUcpCheckoutKit() | UCP 2026-08-25 | POST /checkout-sessions, GET/PUT /checkout-sessions/{id}, POST …/{id}/complete, POST …/{id}/cancel, GET /orders/{id} (SDK 5.18.0) |
createAcpCheckoutKit() | ACP | The ACP checkout-session routes under your basePath |
Both return an object with a fetch-style handle(request) and resolvePending(). The UCP kit also
returns profile and profileHandler() for GET /.well-known/ucp.
import { createUcpCheckoutKit, expressHandler } from '@astrasyncai/verification-gateway';
import { createClient } from '@astrasyncai/verification-gateway/sdk';
const astrasync = createClient({
apiBaseUrl: 'https://astrasync.ai/api', // always includes /api
apiKey: process.env.ASTRASYNC_API_KEY!,
});
const kit = createUcpCheckoutKit({
profile: {
endpoint: 'https://shop.example.com/ucp/v1',
keys: [publicJwk],
paymentHandlers: ['ai.astrasync.card_token'],
ap2Mandates: true,
},
signingKey: { privateKey, kid: publicJwk.kid },
currency: 'AUD',
catalog: async (id) => products.find(id), // { id, title, price (minor units), image_url?, description? } or null
verifyPurchase: ({ checkoutSessionId, astraId, attemptId, checkoutJwt, valueMajor }) =>
astrasync.confirmCheckout({
astraId,
attemptId,
checkoutSessionId,
checkoutJwt,
transactionValue: valueMajor,
currency: 'AUD',
counterpartyUrl: 'https://shop.example.com',
}),
settle: async ({ result }) => chargeWithYourProcessor(result), // see below
reportSettlement: (r) => astrasync.reportSettlement(r),
fulfil: async ({ session }) => orders.create(session), // → { id, permalink_url? }
store: checkoutStore, // durable, see below
idempotencyStore, // durable, see below
links: [
{ type: 'terms_of_service', url: 'https://shop.example.com/terms' },
{ type: 'privacy_policy', url: 'https://shop.example.com/privacy' },
],
});Product images (SDK 5.20.0). Your catalog() item can carry image_url and description.
image_url must be an absolute http(s) URL (use HTTPS); the kit drops a relative or invalid value
when it prices the line. The UCP kit returns both on line_items[].item; the ACP kit returns them
on each line item's item and on the order's line items. Use PNG, JPEG or WebP (no SVG), square,
1000×1000 recommended, at most 2 MB: the same image rules as the
bridge catalog.
Mount it on Express (SDK 5.18.0)
expressHandler(kit) runs a kit on Express. The kit checks signatures and idempotency
fingerprints over the exact request bytes, so give it the raw body:
app.get('/.well-known/ucp', (_req, res) => res.json(kit.profile));
app.use('/ucp/v1', express.raw({ type: '*/*' }), expressHandler(kit));When the AstraSync Express middleware runs first, the handler scopes sessions and idempotency keys
to the caller's verified ASTRA-id. Without it the kit scopes to the Authorization header, then
X-Astra-Id. There is never a shared anonymous scope.
Durable stores
The kit keeps checkout sessions in a CheckoutStore and replayed responses in an
IdempotencyStore. The defaults are in memory: sessions are lost on restart and are not shared
across instances. SDK 5.18.0 warns once at startup when a kit runs on the in-memory defaults
outside tests. Pass your own stores in production.
interface CheckoutStore {
get(id: string): Promise<CheckoutSession | null>;
put(session: CheckoutSession): Promise<void>;
// SDK 5.18.0, optional: atomic compare-and-set of the session status.
claim?(
id: string,
from: CheckoutSession['status'],
to: CheckoutSession['status']
): Promise<boolean>;
}claim(id, from, to) sets the status to to only when it is currently from, and returns
whether this call won. The kit claims complete_in_progress before it verifies the purchase, so
two concurrent completes of one checkout can never both charge. A busy UCP checkout comes back
unchanged with a recoverable checkout_in_progress message; ACP answers 409. Implement it
atomically, for example UPDATE checkout SET status = $to WHERE id = $id AND status = $from.
Without claim the kit warns once and the lock is best-effort.
The purchase flow
On complete, the kit calls your verifyPurchase (normally client.confirmCheckout). Only the
confirm leg can settle; quotes never move money.
confirmCheckoutverifies the agent and applies the owner's policy.- Granted: AstraSync records the order and returns
settlementOutcome: { status: 'pending_merchant', orderId }plus what you settle with:- Card, first-party merchant (handler
ai.astrasync.card_token): an agent-blindsettlementToken. Charge it on your own processor integration as merchant of record, with idempotency keyvoucher:<token.jti>andmetadata: { jti, sessionId }. - Stablecoin, any merchant (handler
ai.astrasync.voucher): thesettlementvoucher. Redeem it and move the funds.
- Card, first-party merchant (handler
- Your
settlecharges and returns{ status: 'settled', processorRef },{ status: 'pending' }or{ status: 'failed', failureCode }. - The kit reports the outcome with
reportSettlement({ orderId, status, processorRef, amountMinor, currency }), then calls yourfulfiland returns the order.
Amounts must match the order exactly, and replays are safe. Never pass the settlement token, voucher or mandates to the agent, and never log them.
Holds: the owner must approve
When the purchase needs the owner's approval, the kit never calls settle or fulfil. SDK
5.18.0 treats any step-up signal as a hold (requiresStepUp, requiresApproval,
recommendation: 'step_up_required', settlementOutcome.status: 'requires_approval', or a
stepUpApproval that is not yet consumed):
- UCP: the checkout returns
status: 'requires_escalation'withcontinue_urlset to the owner'sapprovalUrl. - ACP: the checkout returns
status: 'pending_approval'withcontinue_urlset to theapprovalUrl.
After the owner approves, the platform completes the checkout again and the approval is redeemed.
Every purchase needs a proven agent (round 29.1)
AstraSync only lets a purchase settle when the caller is proven to be allowed to act as the agent. It accepts exactly three proofs:
- Connector owner: the agent belongs to the person signed in on the AstraSync connector. AstraSync's bridge supplies this proof on its own.
- Owner approval: the agent's owner approves the purchase with a passkey.
- Runtime challenge: the agent passes a runtime challenge, which works for agents running through a Workforce Guard checkpoint or a self-hosted challenge handler.
When a purchase reaches your kit with none of these, it is held for the owner's approval, the same way a purchase above the agent's limit is held. One approval covers both. Expect holds on direct agent purchases that don't come through the AstraSync bridge. Make sure your Events URL is set (below), so an approved hold completes on its own.
Approval events: finish a held checkout without the agent (SDK 5.19.0)
When the owner decides, AstraSync sends a signed event to your endpoint's Events URL:
X-AstraSync-Signature: t=<unix>,v1=<hex>[,v1old=<hex>]
{ "eventId", "type": "step_up.approved" | "step_up.denied" | "step_up.expired" | "step_up.lapsed",
"approvalId", "checkoutSessionId", "kitSessionId"?, "protocol"?: "ucp" | "acp",
"counterpartyId", "agentAstraId", "transactionValue", "currency", "decidedAt", "redeemBy"? }Mount the handler next to your kits:
app.post(
'/astrasync/events',
express.raw({ type: '*/*' }),
approvalEventsHandler({ secret: process.env.ASTRASYNC_WEBHOOK_SECRET!, kits: [ucp, acp] })
);- Approved: the kit runs
validateCart, confirms at the held amount (your catalogue is not re-priced), then settles, reports and fulfils. No agent call is needed. - Denied, expired or lapsed: the session goes back to payable with an
approval_denied,approval_expiredorapproval_lapsedmessage. From SDK 5.20.0 the message isrecoverable. Platforms shouldn't retry on their own afterapproval_denied. Afterexpiredorlapsed, completing again asks for a new approval. - Retries: answer 2xx once the session is finished, and 5xx for a temporary failure, so
AstraSync retries. An approved event is retried for about 10½ minutes inside its 15-minute
redeemBy. If it can't be delivered by then, it is followed bystep_up.lapsed. - Secret rotation: for 24 hours after a rotation, the previous secret signs a second value,
v1old. Pass[current, previous]as the secret. - No Events URL: a held kit purchase on an endpoint with no Events URL raises a warning on the endpoint and in Activity, because the hold can't finish without one.
Set the Events URL in the dashboard, and test it from code (SDK 5.21.0)
After registration, endpoint settings change in the dashboard only, behind your login and MFA. Set the Events URL (and the catalogue, checkout, skill and health check URLs) on the endpoint page. Every change to one of these URLs alerts the endpoint's owner.
Why: your API key lives in your host's environment. If the host is ever compromised, a leaked key
must not be able to point agents at someone else's checkout or catalogue under your verified
identity. SDK 5.21.0 removed updateEndpoint, and PATCH /api/endpoints/:id refuses API keys.
From code you can still send a test event:
import { createClient } from '@astrasyncai/verification-gateway/sdk';
const client = createClient({ apiBaseUrl, apiKey, counterpartyId: 'ASTRAE-…' });
const r = await client.sendTestEvent(undefined, { type: 'step_up.lapsed' });
// r = { delivered, status: 'delivered' | 'failed' | 'not_configured', responseCode, error, eventId }sendTestEvent(POST /api/endpoints/:id/events/test) delivers one signed, synthetic event now (approvalId: "test_…"). Your kit acknowledges and ignores it. It isn't retried.- Use it to test
step_up.lapsed, which otherwise only happens when delivery fails for the whole retry window. - A real approval's payload (for a held kit approval at this endpoint, so your resume path runs)
is sent from the endpoint page in the dashboard only.
approvedre-confirms and is held again, with nothing charged; the other types release the session.
Pending settlements (SDK 5.18.0)
When your processor confirms asynchronously, return { status: 'pending' } from settle. The kit
keeps the session in complete_in_progress with no order. When your processor's webhook arrives,
finish it:
await kit.resolvePending(sessionId, { status: 'settled', processorRef: charge.id });
// or: { status: 'failed', failureCode: 'card_declined' }resolvePending reports the settlement and runs fulfil (or the failed path). The stored
session never holds the settlement token, voucher or mandates.
UCP specifics
- Payment handlers. List the handlers you accept in the profile.
astrasyncPaymentHandlers()builds the entries (ai.astrasync.card_tokendeclares card instruments,ai.astrasync.voucherstablecoin). SDK 5.18.0 requires exactly one payment instrument on complete, with ahandler_idfrom your profile; anything else ispayment_failed. links(SDK 5.18.0). Terms, privacy, refund, shipping and FAQ links are returned on every checkout and covered by the AP2merchant_authorizationsignature.- Agent binding (SDK 5.18.0). After
verifyPurchase, the kit runscheckUcpAgentBindingautomatically: theUCP-Agentprofile must match the UCP profile the agent registered with AstraSync (fromresult.agent.protocolIdentities). A mismatch never settles. By default an agent with no registered UCP profile passes (for example, calls relayed by the AstraSync bridge); setucpAgentBinding: 'strict'to refuse those too, or'off'to skip the check. PassresolveIdentities(req, result)to supply the identities yourself. - Orders (SDK 5.18.0).
GET /orders/{id}returns the order entity (buildUcpOrder). Setwebhookto send signed order webhooks when an order is created, including fromresolvePending.
What the verification result gives you
result.mandates: AP2 v0.2 Checkout + Payment mandates, when your endpoint listsap2and the request was granted. Closed mandates needcheckoutJwtandtransactionValueonconfirmCheckout(the UCP kit passes its signed checkout ascheckoutJwt). See AP2.result.agent.protocolIdentities: the protocol identities the agent registered (agent key, UCP profile, key directory, wallet). Bind an incoming protocol request to the verified agent with them.result.settlementOutcome: read itsstatus, neversuccessalone. Treat a status newer than your SDK as pending, never failed.
MPP: confirm before the 402
If you also accept MPP, run confirmCheckout before you
issue the 402 challenge. A hold then surfaces before the agent spends any single-use payment
credential. The Stripe shared payment token handler is not enabled.
Routes that hold their own approval
If one of your routes asks the owner for approval itself (it creates its own hold and answers
202 while it waits), set approvalHeldByRoute on the Express middleware so the owner isn't asked
twice:
createMiddleware({
// …
approvalHeldByRoute: (req) => req.method === 'POST' && req.path === '/signin',
});A result that only needs the owner's approval then reaches your handler
(req.agentVerification.requiresStepUp is true), and verify-access creates no approval. Any deny
is still refused.
Route patterns in dashboard rules
Endpoint route rules in the dashboard use the same patterns everywhere (the Express and Next.js middleware, edge config and the AstraSync bridge):
| Pattern | Matches | Example |
|---|---|---|
* | Any characters, including / | /ucp/v1/checkout-sessions/* |
:name | Exactly one path segment | /ucp/v1/checkout-sessions/:id/complete |
Everything else is literal. Patterns are anchored at both ends.
Unmatched routes on Express (SDK 5.18.0)
A request that matches no route rule passes through by default. Set
defaultRoutePolicy: 'observe' on the Express middleware to evaluate unmatched routes for the
audit trail instead. With evaluateAlwaysIfCredentialed: true, an unmatched request that carries
an X-Astra-Id is also evaluated in observe mode. Observe mode populates req.agentVerification
and never vetoes the request.
createMiddleware({
// …
defaultRoutePolicy: 'observe', // default 'pass'
evaluateAlwaysIfCredentialed: true,
});Not available yet
createMastercardRegistry()is an inert stub until Mastercard publishes its agent registry: it resolves no keys. See Payment networks.- Card settlement is first-party only: it runs for merchants on AstraSync's own processor account.

