How we shipped AP2 v0.1 in production.
Mandate chains, did:web key resolution, and eight gaps Google's spec leaves you to fill in. A field report from one of the only production AP2 implementations on the open internet.
Mandate chains, did:web key resolution, and eight gaps Google's spec leaves you to fill in. A field report from one of the only production AP2 implementations on the open internet.
did:web, fetching /.well-known/did.json. We persist private JWKs sealed with AES-256-GCM in signing_keys.sealed_private_jwk, ES256 / P-256 throughout.ap2_mandates with a UNIQUE on (account_id, mandate_hash). Caller-supplied Idempotency-Key handled separately in middleware.AP2 — the Agent Payments Protocol — was announced by Google Cloud and Coinbase in September 2025. It defines a cryptographic envelope around agent-initiated transactions so every party in the chain can independently verify: this payer authorized this spend, on this cart at this merchant, up to this cap, before this expiry.
The protocol does not move money. It produces signed JSON objects (JWS — JSON Web Signatures, RFC 7515) that travel alongside whatever rail you're using. Card networks, ACH, SEPA, USDC — all rail-agnostic. AP2 just answers the "who authorized this?" question definitively, so the regulator, the merchant, and the payer all see the same truth.
The reference implementation Google published is in Go and depends on a particular signing-service architecture. We ignored it. The spec itself is portable — we re-implemented it from scratch in TypeScript over a long weekend, then spent six weeks finding the gaps below.
This is where most third-party AP2 implementations get it wrong. Two parties sign across the chain, not one.
Issued by the payer (`iss` = payer DID). Says: "I, the payer, authorize spend up to $N for purpose P, before time T, optionally delegated to agent DID A."
Created when a user grants an agent a spending budget. Lives until expiry or revocation. One IntentMandate can authorize many carts.
Issued by the merchant (`iss` = merchant DID). Says: "Here is the cart I am offering — these items, this total, this currency — referencing the inlined IntentMandate as my parent."
Pinned at checkout, before payment. Carries the parent IntentMandate JWS verbatim under parent.jws.
Issued by the payer (`iss` = payer DID). Says: "I confirm settlement of the inlined CartMandate via this rail with this payment instrument."
Signed at the moment of settlement. Carries the CartMandate JWS verbatim under parent.jws — which carries the IntentMandate JWS inside it. Three signatures, one envelope.
The verifier walks the chain inside-out: parse PaymentMandate → verify with payer's JWKS → extract `parent.jws` → parse CartMandate → verify with merchant's JWKS → extract `parent.jws` → parse IntentMandate → verify with payer's JWKS again → cross-check totals, expiries, and DIDs. If any link's `iss` doesn't match the expected role, the whole chain is rejected.
AP2 uses did:web to resolve party identities to public keys. did:web:agentwallet.ai resolves to https://agentwallet.ai/.well-known/did.json. The DID document either inlines `verificationMethod[].publicKeyJwk` entries directly or carries a `service` entry with `id: "#ap2-jwks"` and a `serviceEndpoint` URL pointing to a JWKS document.
That's the entire PKI. No certificate authority, no certificate chains, no rotation ceremony with a quorum of operators. Whoever owns the domain owns the DID; whoever owns the DID can publish and rotate keys.
Private keys live in our signing_keys Postgres table with columns kid, alg, environment (development/production), sealed_private_jwk (AES-256-GCM sealed via lib/crypto.ts), public_jwk (plain JSONB), activated_at and revoked_at.
Curve and algorithm are uniform across the system: ES256 / P-256. We considered Ed25519; the broader JWS toolchain in our partner stack handles ES256 more reliably.
The full verification path runs on every inbound POST /ap2/v1/payment. Six steps, in order, all fail-closed:
parent.jws — the inlined CartMandate.ap2_mandates; ON CONFLICT → replay, return prior result.Any step fails → reject the request with a structured RFC 7807 error. No partial accepts. No "warn and continue." This is the contract — if the chain doesn't verify, the mandate doesn't persist, and downstream rails never see it.
These are issues we hit in staging or production. Some are spec ambiguities, some are implementer footguns, all cost us at least half a day. If you're implementing AP2, copy this list to a sticky note.
RFC 7519 `exp` claims are integer seconds. `Date.now()` returns milliseconds. We caught this in staging when every signed mandate looked like it expired in 1973. Divide by 1,000 or use seconds-since-epoch explicitly. Add a sanity check: refuse any mandate whose `exp` is in the past or more than 24 hours in the future.
This is the most common implementation error we've seen. The IntentMandate and PaymentMandate are signed by the payer's DID; the CartMandate is signed by the merchant's DID. A verifier that uses one JWKS for all three will silently accept forged carts. Our chain verifier resolves two DIDs per transaction — payer for Intent + Payment, merchant for Cart — and refuses the chain if the `iss` claim on any mandate doesn't match the expected party.
The AP2 envelope nests: PaymentMandate carries `parent.jws` which is the verbatim CartMandate JWS string, and CartMandate carries `parent.jws` which is the verbatim IntentMandate JWS. There is no bare "mandate_id" cross-reference. That means your verifier has to re-verify each parent inline rather than fetching by ID. We deduplicate by hashing the whole chain (`sha256(payment_jws)`) — not by any single field.
IntentMandate `amount_max` is denominated in whatever currency the payer chose. If the cart prices in a different currency, somebody has to convert. The spec is silent on which rate, when. Our current verifier compares same-currency totals only and rejects cross-currency carts — we'd rather refuse than silently convert at a rate the payer didn't authorize. The right long-term fix is snapshotting an FX rate at IntentMandate issuance and pinning it; the wrong fix is converting at the rate live at payment time, because that's a covert spending-cap bypass.
RFC 7515 makes `kid` optional and AP2 inherits the laxity. Our `verifyJws` iterates every key in the resolved JWKS (typically 1–3 keys per did:web document) until one verifies. If you expect to publish more than ~10 keys per DID you'll want to enforce `kid` and fail closed on its absence — but for the realistic key-rotation window, trial-verifying is cheaper than the operational cost of forcing every signer to populate `kid`.
Resolving `did:web:example.com` means fetching `https://example.com/.well-known/did.json`. An adversary controlling the DID string controls the URL your server fetches. We pipe every resolution through `assertSafeOutboundUrl`: HTTPS only, no loopback / private / link-local / `.local` hostnames, no literal IPs in production, and a hard timeout. The spec mentions DID resolution but doesn't tell you to harden it.
If a cart authorizes $100 and the merchant captures $97 (taxes adjust, partial refund, line-item drop), what's the mandate state? AP2 v0.1 doesn't say a word. We persist `amount_authorized` and `amount_captured` as separate columns on the payment row and emit our own `mandate.partial_capture` webhook event. Anyone consuming our audit trail can reconstruct the truth; an AP2-purist verifier won't see the partial because the spec has nowhere to put it.
The Go reference is fine as a conformance probe but its architecture (a daemon all your callers hit to sign on their behalf) leaks into the spec. Wallet-side signing — where each agent holds its own sealed key and signs in-process — is what production payers actually want, and the spec doesn't model it. We implemented from the spec text, ignored the reference, and shipped in ~400 lines of TypeScript using `jose`.
We deduplicate by hashing the entire PaymentMandate JWS — header.payload.signature, the canonical compact serialization — with SHA-256 and persisting it in an ap2_mandates table whose UNIQUE constraint is (account_id, mandate_hash). Insert fails → return the previously-recorded verification result idempotently.
Why hash the whole JWS rather than dedup by some mandate id? Because there is no canonical id field — the mandate identity in AP2 is the signature itself. Two re-signs of the same payload produce two JWS strings (ECDSA is non-deterministic), but a well-behaved signer doesn't re-sign for retries; if the payer's client retries, it sends the byte-identical JWS, and we collapse.
Caller-supplied Idempotency-Key headers ride a separate idempotency_keys table with scope: "ap2" — that's the request-replay guard, distinct from the mandate-replay guard. Stripe-style idempotency on top of mandate-hash dedup underneath.
Operators rotate keys. The naive implementation breaks every previously-signed mandate the moment a key is replaced.
Our approach: the signing_keys table carries activated_at and revoked_at columns, and the did:web JWKS we serve includes every non-revoked key. A verifier looking up an old `kid` finds it in the published JWKS and the JWS verifies. Operationally we treat key removal as "revoke + stop publishing" — once a key drops from the JWKS, mandates signed by it stop verifying for everyone, including us. Time-bounded validation (accept JWS only if `iat` falls between `activated_at` and `revoked_at`) is on the roadmap; today the trust horizon is "is it in the JWKS right now."
Long-term audit lives separately. ERC-8004 identity registrations for our Principals and Agents are anchored on Base Sepolia via PrincipalRegistry and AgentRegistry contracts. That's entity-level, not per-mandate — per-mandate anchoring on every payment was a cost we couldn't justify at sub-3-second settlement.
Probably not. AP2 verification is straightforward — the chain logic above is ~400 lines. AP2 operations at scale (key rotation, did:web hardening, mandate-hash replay tables, FX snapshotting, the gap list above) is six engineer-weeks of work that has to be done before you take real money.
If you're shipping a B2C shopping agent on Stripe Link, you don't need AP2 — Stripe's consumer-side trust model substitutes for it (the human is in the loop approving every spend). If you're shipping an autonomous agent that transacts unattended — pays APIs, settles payouts, buys inputs — AP2 is the only published standard that gives you an auditable answer to "who told you to spend that money?".
The path of least resistance is to use a provider that's already shipped AP2 v0.1 to production. AgentWallet does. Your agent posts a signed PaymentMandate to POST /ap2/v1/payment; we verify the chain, dedupe against ap2_mandates, persist the receipt, and return a verified-mandate handle the rest of your stack can hand to the settlement rail of its choice. For browser-side commerce we wire AP2 into our agent toolset under the agent_purchase MCP tool, which takes a cartMandateUid and binds spend to that one approved cart.
If you'd rather build it yourself: copy the gap list. Wire the mandate-hash dedup table before the JWKS resolver. Snapshot FX rates at IntentMandate issuance time. Harden did:web with an SSRF guard from day one. Test against an adversarial signer that swaps `iss` claims to try the role-confusion attack from #02.
Authorization only. AP2 produces a signed mandate-chain that proves a payer authorized a specific cart at a specific merchant up to a specific cap. It does not move money. You still need a settlement layer underneath — cards, ACH, USDC, whatever — to actually charge the buyer. At AgentWallet we settle via Payouts.com's 17 fiat rails and Coinbase CDP for USDC on Base.
AP2 is about WHO is allowed to spend, and HOW MUCH. x402 is about HOW the API server tells the agent the price (HTTP 402 Payment Required + USDC on Base). ACP (Stripe's Agentic Commerce Protocol) is about WHAT credential the agent presents at a merchant — a Shared Payment Token bound to one cart, one merchant. They compose: AP2 mandate authorizes the spend → ACP SPT presents the card credential → x402 handles the API micro-payment leg. AgentWallet implements all three in production.
No. AP2 is an open spec. The reference implementation Google published is in Go and assumes a particular signing-service architecture, but the protocol itself is just signed JWS objects keyed by did:web. We implemented it in TypeScript on Node 24 with the `jose` library. The full mandate-chain verification path is roughly 400 lines of code.
As of May 2026, we're aware of AgentWallet (us), Stripe (limited pilots with Link), and Google's own first-party demo. Cobo wrote the canonical public explainer for AP2 but does not implement the protocol — they're a custody layer that holds keys agents use to sign. Crossmint, Ramp, Circle, and Coinbase Agentic Wallets have no AP2 implementation as of writing.
`POST /ap2/v1/payment`, taking a PaymentMandate JWS in the body. The handler verifies the full chain, deduplicates against `ap2_mandates(account_id, mandate_hash)`, persists the receipt, and returns a verified-mandate handle. Settlement on the underlying rail (card / ACH / USDC) is a separate call against the same mandate handle. Public key discovery is at `GET /.well-known/did.json`, `GET /.well-known/ap2-keys.json`, and `GET /.well-known/jwks.json`.
Express 5 + TypeScript 5.9, `jose` for JWS (compactVerify / importJWK / SignJWT), Drizzle ORM + Postgres for the `signing_keys` and `ap2_mandates` tables, AES-256-GCM-sealed private JWKs (column `sealed_private_jwk`), ES256 / P-256 across the board, WebAuthn-bound principals at the human end, did:web resolution via `did:web:agentwallet.ai` and per-agent did:web subdomains. Source lives at `artifacts/api-server/src/lib/ap2.ts` (verification, key resolution, replay guard) and `artifacts/api-server/src/routes/ap2.ts` (the HTTP surface).
AgentWallet ships AP2 v0.1 to production today. Sign mandates from your agent, post to one endpoint. Mandate-chain verification, did:web key resolution, replay protection, and JWKS-driven key rotation all handled.
Source: agentwallet.ai/blog/ap2-deep-dive · Published May 18, 2026