Experimental. The gateway can require an AP2 v0.2.0 Direct Checkout
Mandate before a paid resource settles. AP2 is disabled unless configured with
enabled: true.
This is merchant-side mandate verification, not a complete AP2 Merchant implementation. The AP2 provider verifies closed mandates but does not issue mandates or signed Checkout Receipts.
| Property | Supported value |
|---|---|
| Specification | AP2 v0.2.0, tag 2026-04-28, commit b4587ac |
| Mode | Direct (Human-Present) |
| Delegation | Trusted Agent Provider: the mandate in delegate_payload |
| Mandate type | closed Checkout Mandate, vct: mandate.checkout.1 |
| Signature | ES256 with P-256 |
| Trust | static public keys in config.yaml |
A successful verify-and-reserve call shows that, immediately before its reservation:
- a configured mandate issuer signed it with the named configured key;
- each time claim it carries is valid, and any
audnames the configured audience; - it binds a checkout JWT signed by a configured checkout issuer;
- that checkout JWT matches the resolved resource, validated input, price, payment method and settlement coordinates;
- the mandate identity had no
reserved,consumedoruncertainreplay record, and no other non-released mandate held the checkout JWTjti.
The call then reserves the mandate identity and checkout jti atomically.
Authorization does not move money or unlock a paid resource by itself. The request still needs a valid payment proof.
CanonicalRequest
-> resolve resource, validate input, resolve price
-> no payment proof? return the payment and AP2 requirements
-> verify payment proof
-> verify and reserve mandate AUTHORIZATION_*
-> reserve payment replay key PAYMENT_REPLAYED
reservation failure -> release mandate
-> settle payment, then call merchant backend (upfront)
or call merchant backend, then settle payment (authorization)
backend failure before settlement -> release mandate
-> finalize mandate from the payment or backend outcome
-> store receipt with mandate digest
Payment verification runs before mandate reservation and must not move funds.
The mandate is reserved before settlement to block concurrent reuse. If
recording the payment attempt fails, the pipeline releases the mandate before
settlement. A backend failure under the authorization flow also releases the
mandate because settlement has not started. Otherwise the settlement outcome
determines its final state.
AP2 leaves the checkout payload to the implementation. This gateway requires:
agent-commerce/ap2/checkout/v1
The identifier is a namespace, not a URL, and is frozen as a signed wire value.
The mandate is an SD-JWT presentation in AP2's Trusted Agent Provider shape.
The agent provider signs the token. The verifier reads the mandate from the
single object element of delegate_payload after resolving disclosures. The
header must name a configured kid.
Claims of the issuer-signed token:
| Claim | Required | Rule |
|---|---|---|
delegate_payload |
yes | exactly one object element after disclosure resolution |
iss |
no | configured mandate issuer; without it, kid alone selects the issuer |
aud |
only with requireMandateAudience |
issuer's configured audience |
iat |
no | no further in the future than allowed clock skew |
nbf |
no | no further in the future than allowed clock skew |
exp |
no | checked with allowed clock skew; see requireMandateExpiry below |
_sd_alg |
when present | sha-256 |
Claims of the mandate, the disclosed element:
| Claim | Required | Rule |
|---|---|---|
vct |
yes | exactly mandate.checkout.1 |
checkout_hash |
yes | base64url(SHA-256(compact checkout JWT)) |
checkout_jwt |
yes | compact merchant checkout JWT after disclosure resolution |
iat |
no | as on the token |
nbf |
no | as on the token |
exp |
no | as on the token; also satisfies requireMandateExpiry |
A top-level vct, such as com.example.agent_mandate, names the credential
type; the verifier reads the mandate's vct instead. It refuses these as
unsupported_mandate_type:
- top-level mandate claims without
delegate_payload; - presentations with a KB-JWT, and
~~-joined delegation chains; - a delegation hop presented alone: a header
typofkb+sd-jwt,kb-sd-jwt,kb+sd-jwt+kborkb-sd-jwt+kb, or a top-levelsd_hashorissuer_jwt_hash; - mandates carrying
cnf. The gateway cannot verify acnfholder-key binding because it does not accept KB-JWTs; - a closed mandate carrying
constraints, which only open mandates use and the verifier does not evaluate.
AP2 makes the token's aud, iat and exp optional. One reference SDK vector
omits all three. The verifier checks these claims when present. Operators can
require aud, or require exp on either the token or the mandate content.
The checkout JWT independently requires an audience and expiry, and replay
protection checks reuse of the signed mandate.
tests/fixtures/ap2/v0.2.0 contains mandates minted with the AP2 reference
SDK. The verifier tests use them.
| Claim | Required | Rule |
|---|---|---|
iss |
yes | configured checkout issuer |
aud |
yes | issuer's configured audience |
iat |
yes | no further in the future than allowed clock skew |
exp |
yes | required and checked |
jti |
yes | opaque id stored for replay defense and receipt reconciliation |
agent_commerce |
yes | profile object below |
The first six fields are required, non-empty strings. An absent field is a mismatch.
| Field | Compared with |
|---|---|
profile |
agent-commerce/ap2/checkout/v1 |
resource_id |
resolved resource id |
input_hash |
validated canonical input digest |
amount |
resolved decimal price string |
currency |
resolved currency |
payment_method |
selected payment provider |
destination |
payment requirement destination |
network |
payment requirement CAIP-2 network |
asset |
payment requirement asset |
For destination, network and asset, a value present on either side
must be present and equal on both. x402 and MPP requirements name all three.
EVM address comparisons ignore letter case because EIP-55 encodes the checksum
through casing. Every other field, including network, must match exactly.
Amounts are compared as strings, so 0.10 and 0.1 differ.
The mandate is checked against the resolved request; it does not supply values used to construct the purchase.
input_hash = base64url(SHA-256(RFC 8785 JCS(validated input)))
The hash uses RFC 8785 JSON Canonicalization Scheme, including its number
formatting and UTF-16 key ordering. It covers validated canonical input after
_payment and _authorization are stripped. It excludes request ids and
transport metadata. Backend input bindings may subsequently map that canonical
input into path, query and body values.
The merchant signs checkout JWTs outside the gateway and gives them to the
buyer. The gateway verifies them with public keys under checkoutIssuers; it
has no checkout-JWT issuance route.
import { createCheckoutJwt } from '@devlab.group/agent-commerce/ap2';
const jwt = await createCheckoutJwt({
privateKey,
kid: 'checkout-2026-01',
issuer: 'https://merchant.example',
audience: 'agent-commerce',
resourceId: 'market_report',
input: { city: 'Berlin' },
amount: '0.01',
currency: 'USDC',
paymentMethod: 'x402',
destination,
network,
asset,
});createCheckoutJwt computes the RFC 8785 input digest and rejects missing
required strings, numeric amounts, public or non-P-256 JWKs, and non-PKCS#8 PEM
strings. It cannot compare its inputs with the gateway's eventual payment
requirement. Take price data from the merchant catalog and settlement
coordinates from the payment challenge.
For a challenge with several x402 offers, choose one before creating the JWT.
Pass its payTo, network and asset as destination, network and
asset. The mandate must match the proof's selected offer: Base USDC approval
does not authorize Solana or NEAR USDC. Copy Solana and NEAR addresses exactly;
only EVM addresses compare case-insensitively. Keep amount and currency
as the resource's price, even when payment records use the settled token.
The buyer's agent or credential provider creates the mandate around this JWT. This package does not mint mandates.
Verification uses operator-configured public keys only:
trust.mandateIssuersandtrust.checkoutIssuersare separate;- each issuer has a required, non-defaulted audience;
issandkidmust select an exact configured key. A mandate withoutissis matched bykidalone and refused when two mandate issuers share thatkid;- keys are restricted to public P-256 JWK members;
- the verifier does not fetch JWKS, issuer metadata,
jkuorx5u.
Deploy the new public key beside the old one, switch the signer
to the new kid, then remove the old key after old documents expire. Removing
a key from config and restarting is the available revocation mechanism.
clockSkewSeconds defaults to 60 and cannot exceed 300. It applies to
exp, nbf and iat on the signed mandate token, and to time claims on the
checkout JWT. It also applies to exp, nbf and iat in the mandate content.
An iat or nbf beyond the permitted future skew is refused as expired, and
a non-numeric exp or nbf, on the token or in the content, as
invalid_claims. The mandate's time claims are optional; the checkout JWT
requires iat and exp.
AP2 replay state lives in its own SQLite database at
authorization.ap2.replay.path.
A mandate's replay reference hashes its issuer-signed SD-JWT token, so
selective-disclosure variants share a reference. Another valid signature can
produce a different hash for the same claims. The checkout JWT jti is also
reserved, preventing either variant from settling twice.
| State | Meaning | Reusable? |
|---|---|---|
reserved |
settlement outcome not known yet | no |
consumed |
settlement succeeded | no |
released |
failure proved that no funds moved | only if no other non-released row has the same checkout jti |
uncertain |
settlement may have been broadcast | no |
No replay rows are swept. Deleting a reserved, consumed or uncertain row
could make a mandate spendable again, so a bounded deployment should archive
only released rows. A crash between settlement and the final replay-state
update leaves the row reserved, favoring a refused retry over a possible second
payment.
A receipt stores a digest and reconciliation identifiers:
{
"authorization": {
"method": "ap2",
"reference": "sha256:BASE64URL",
"metadata": {
"mandateIssuer": "https://surface.example",
"checkoutIssuer": "https://merchant.example",
"checkoutId": "checkout_01K..."
}
}
}The built-in gateway does not persist the presentation, disclosures, checkout
JWT or Agent-Authorization header. Its logger redacts the header, and receipt
records contain only the summary above.
The digest identifies an accepted mandate but cannot reconstruct the buyer's evidence. A merchant that needs dispute-grade evidence must retain it outside this receipt store.
| Code | HTTP | Meaning |
|---|---|---|
AUTHORIZATION_REQUIRED |
403 | after payment verification, required authorization was absent or did not satisfy every required method |
AUTHORIZATION_INVALID |
403 | malformed, untrusted, expired or mismatched proof |
AUTHORIZATION_REPLAYED |
409 | mandate or checkout JWT has a reserved, consumed or uncertain row |
AUTHORIZATION_PROVIDER_UNAVAILABLE |
503 | verifier or replay store did not produce a verdict; retryable |
With no payment proof, the pipeline returns a payment-required response before
checking authorization. After payment verification, missing and invalid
authorization use 403 because another payment cannot repair them. Replay uses
409, and provider unavailability uses 503. Rejection reasons are deliberately
coarse, such as untrusted_issuer, invalid_signature, expired and
purchase_mismatch; reporting field-level purchase mismatches would let a
caller read a mandate by elimination.
HTTP, MCP and A2A carry this envelope:
{ "method": "ap2", "payload": "<SD-JWT presentation>" }| Surface | Carrier |
|---|---|
| HTTP | base64url-encoded JSON in Agent-Authorization |
| MCP | reserved _authorization tool argument |
| A2A | reserved _authorization input field |
AP2 v0.2.0 defines no transport carrier, so all three are this gateway's own.
A mandate sent under another key, such as UCP's ap2.checkout_mandate, is not
read.
The HTTP header is limited to 8192 encoded bytes before decoding. Transport
adapters preserve payload byte for byte. Reserved fields are removed before
resource validation, hashing and backend execution.
With the A2A x402 extension, a payment message can carry the mandate when the first message did not; see x402 extension.
See configuration.md for the YAML schema and validation rules. A free resource cannot require AP2 because authorization gates settlement.
Install the optional AP2 peers with:
npm install @devlab.group/agent-commerce jose @sd-jwt/core canonicalizeagent-commerce doctor reports the AP2 pin, trusted issuer ids and key
counts, replay-store status, gated resources and unsupported list.
GET /.well-known/agent-commerce reports the descriptor under
authorizationProviders.
Whoever creates an AP2 provider must call close(). The application
composition root does so after gateway.close(); createGateway() itself
does not close authorization providers.
src/authorization/ap2/descriptor.ts is authoritative for the unsupported list
published through discovery and doctor. The broader implementation limits are:
- autonomous mode and open Checkout Mandates (
mandate.checkout.open.1) - Payment Mandates (
mandate.payment.1) - spending-constraint evaluation
cnf-bound agent keys, delegation chains and key-binding JWTs- User Credential delegation through OpenID4VP
- JWKS, issuer metadata, remote key discovery or remote revocation
- key rotation without a config change
- signatures other than ES256 or digests other than SHA-256
- mandate issuance or signed AP2 Checkout Receipts
- an AP2 transport adapter or
/.well-known/ap2 - AP2 as a payment rail
- AP2 over ACP
Open mandates are refused because their spending constraints are not evaluated.
| Path | Responsibility |
|---|---|
src/authorization/ap2/ |
verifier, trust, purchase binding and replay store |
src/core/domain/authorization.ts |
generic authorization contract |
src/core/execution/pipeline/admission.ts |
verification and reservation ordering |
tests/integration/ap2-x402-conformance.test.ts |
end-to-end refusal cases |
tests/e2e/authorization/ |
on-chain gated settlement |