Agent side
AP2 mandates
Require proof that a human approved this exact purchase before a payment is allowed to settle.
With AP2 enabled, a paid resource can require a signed AP2 v0.2.0 Direct Checkout Mandate — proof that the human behind the agent approved this exact purchase — verified before the payment is allowed to settle. A mandate never unlocks a resource on its own and never moves money.
What a verified mandate proves
- A configured issuer signed it, with a key that issuer declared.
- Its time claims, when present, show that it has not expired and is not issued or valid only in the future.
- Any audience it names is this merchant.
- It binds a checkout document the merchant signed.
- That document authorizes the resource, input, price and rail in front of us.
- It has not been spent before.
Mandate shape
AP2's Trusted Agent Provider signs the SD-JWT. After resolving disclosures, the verifier reads the mandate from the single object in delegate_payload. The token header must name a configured kid. The mandate itself carries vct: mandate.checkout.1, the compact checkout_jwt and its checkout_hash.
AP2 makes the token's aud, iat and exp optional, so the verifier checks them only when present unless you set requireMandateAudience or requireMandateExpiry. The checkout JWT always requires an audience and expiry. A token without iss is matched by kid alone. These are refused as unsupported_mandate_type:
- mandate claims at the top level, without
delegate_payload; - presentations with a KB-JWT,
~~-joined delegation chains and a delegation hop presented alone; - mandates carrying
cnf, since the gateway accepts no KB-JWT to prove the holder key; - a closed mandate carrying
constraints, which only open mandates use and the verifier does not evaluate.
Set it up
- Install the peers
bash npm install @devlab.group/[email protected] [email protected] @sd-jwt/[email protected] [email protected] - Trust the issuers
yaml authorization: ap2: enabled: true specVersion: "0.2.0" mode: direct clockSkewSeconds: 60 requireMandateAudience: false # true refuses a token without aud requireMandateExpiry: false # true refuses a mandate without exp trust: mandateIssuers: # who may issue a Checkout Mandate - issuer: https://surface.example audience: merchant.example keys: - kid: mandate-2026-01 jwk: { kty: EC, crv: P-256, x: "...", y: "..." } checkoutIssuers: # who may sign YOUR checkout JWT - issuer: https://merchant.example audience: agent-commerce keys: - kid: checkout-2026-01 jwk: { kty: EC, crv: P-256, x: "...", y: "..." }Public keys only. Nothing is fetched — no JWKS, no
jku, nox5u. A JWK carrying private material is refused at load. Give mandate issuers distinctkids: a token withoutisswhosekidtwo issuers share is refused. - Require it on a paid resource
yaml resources: market_report: pricing: { type: fixed, amount: "0.01", currency: USDC } payments: [x402] authorization: required: [ap2]Requiring AP2 on a free resource is refused: there is no settlement to gate.
- Sign the checkout JWT in your own process
typescript import { createCheckoutJwt } from '@devlab.group/agent-commerce/ap2'; const jwt = await createCheckoutJwt({ privateKey, // a private JWK, or a PKCS#8 PEM kid: 'checkout-2026-01', // must match a configured key issuer: 'https://merchant.example', audience: 'agent-commerce', resourceId: 'market_report', input: { symbol: 'ETH' }, // hashed with RFC 8785 (JCS) amount: '0.01', // a string, from your own catalog currency: 'USDC', paymentMethod: 'x402', // or 'mpp': the rail that will settle destination, network, asset, // the payTo, network and asset of the offer to pay });With several x402 offers, choose one before creating the JWT. The gateway checks the mandate against the offer the proof selects, so approval for USDC on Base does not authorize USDC on Solana or NEAR. Copy Solana and NEAR addresses exactly; only EVM addresses compare case-insensitively. Keep
amountandcurrencyas the resource's price, even when the payment records the settled token.The mandate that wraps this JWT is signed by the buyer's agent or credential provider with a key listed under
mandateIssuers.
Carrying a mandate
One envelope, three transports:
{ "method": "ap2", "payload": "<the SD-JWT presentation>" }| Surface | Carrier |
|---|---|
| HTTP | Agent-Authorization header, base64url of that JSON (max 8192 bytes) |
| MCP | the reserved _authorization tool argument |
| A2A | the reserved _authorization input field |
Reserved fields are stripped before validation, so _authorization never reaches your backend and never enters the input hash. AP2 v0.2.0 defines no transport carrier, so all three are this gateway's own; a mandate under another key, such as UCP's ap2.checkout_mandate, is not read. With the A2A x402 extension, the payment message can carry the mandate when the first message did not.
Errors
| Code | HTTP | When |
|---|---|---|
AUTHORIZATION_REQUIRED | 403 | after payment verification, the required mandate was absent |
AUTHORIZATION_INVALID | 403 | signature, trust, binding, time or purchase mismatch |
AUTHORIZATION_REPLAYED | 409 | the mandate is good, and already spent |
AUTHORIZATION_PROVIDER_UNAVAILABLE | 503 | our verifier or store failed — retryable |
With no payment proof at all, the gateway answers the 402 challenge first and does not check the mandate. After that, 403, not 402: the buyer's money is not the problem, and a client that auto-pays on 402 would be charged for a request that was never going to be delivered. Rejection reasons stay coarse, such as purchase_mismatch, so a caller cannot read a mandate by elimination.
Replay and receipts
Since 2.6.0, replay state uses the receipt database. To import a 2.5 file, keep replay.path set for the first start; see Deprecated store files. Hashing the signed token makes selective-disclosure variants collide. Different signatures over the same claims have different hashes, so reserving the checkout JWT's jti blocks those variants.
| State | Meaning | Reusable? |
|---|---|---|
reserved | settlement outcome not known yet | no |
consumed | settlement succeeded | no |
released | a failure proved no funds moved | only if no other non-released row has the same checkout jti |
uncertain | settlement may have been broadcast | no |
Under x402's default authorization flow, backend failure releases the mandate because settlement has not started. Replay rows are not swept. Deleting rows other than released, or restoring an older backup, can make mandates reusable. Receipts retain a sha256: mandate digest, issuers and checkout ID, excluding the presentation. Keep dispute evidence elsewhere.
Once a payment is resolved, agent-commerce reconcile and manual resolution (agent-commerce resolve or POST /api/payment-attempts/:id/resolve) try to move a pending reserved or uncertain hold to consumed for a settled payment or released for a rejected one, when AP2 is enabled. Failed updates stay pending in the journal for a later retry. These transitions leave consumed and released rows unchanged and pass the reserving request ID, so a late release cannot change a later request's reservation. For orphan reserved holds without a payment attempt, agent-commerce reconcile releases those older than --min-age (default 15 minutes).resolve does not release orphans.
Rotating a key
List the new public key beside the old one under the same issuer and deploy; move the signer to the new kid; once nothing old is in flight, remove the old key and deploy again. Removing a key and restarting is the revocation.
Not implemented: autonomous mode and open mandates, Payment Mandates (mandate.payment.1), constraint evaluation, cnf-bound keys, delegation chains and key-binding JWTs, User Credential delegation through OpenID4VP, remote key discovery, algorithms other than ES256, mandate issuance, and AP2 over ACP.