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://api.astrasync.ai',
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) } 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' },
],
});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.
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.

