TECHNICAL CASE STUDY · May 2026

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.

By AgentWallet engineering·~12 min read·May 18, 2026

TL;DR

Three mandates, two DIDs, one dedup table, a 400-line verifier.

  • • AP2 is an authorization protocol, not a rail. Signed mandates prove a payer authorized a cart at a merchant up to a cap. Money still moves over cards, ACH, or USDC underneath.
  • • Three mandates: IntentMandate and PaymentMandate are signed by the payer; CartMandate is signed by the merchant. Mandates nest by inlined parent JWS, not by id.
  • • Keys are discovered via 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.
  • • Replay is deduplicated by hashing the PaymentMandate JWS into ap2_mandates with a UNIQUE on (account_id, mandate_hash). Caller-supplied Idempotency-Key handled separately in middleware.
  • • Eight gaps in v0.1 — currency snapshotting, partial settlement, SSRF hardening on did:web resolution, optional `kid`, role confusion between Intent/Cart/Payment signers — get filled in by you, not by the spec.

What AP2 actually is (in 90 seconds)

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.

The three mandates and who signs which

This is where most third-party AP2 implementations get it wrong. Two parties sign across the chain, not one.

01 — IntentMandate

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.

02 — CartMandate

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.

03 — PaymentMandate

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.

did:web for key discovery, not bespoke PKI

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.

Verifying a mandate chain end-to-end

The full verification path runs on every inbound POST /ap2/v1/payment. Six steps, in order, all fail-closed:

  1. 1. Parse the PaymentMandate JWS. Read the JOSE header. Extract `iss` from the payload claims.
  2. 2. Resolve the payer's JWKS via did:web from `iss`. Cache the resolution. Iterate the keys; first one that verifies wins.
  3. 3. Verify `exp` is in the future and within sanity bounds (max 24h in the future). Extract parent.jws — the inlined CartMandate.
  4. 4. Parse the CartMandate JWS. Resolve the merchant's JWKS via did:web from its `iss` (different DID, different document, different keyset). Verify. Confirm the cart references the same payer DID we just verified.
  5. 5. Parse the inlined IntentMandate JWS. Resolve the payer's JWKS again (cached). Verify. Confirm the IntentMandate authorizes the agent referenced in the PaymentMandate.
  6. 6. Confirm the cart total ≤ IntentMandate.amount_max in the same currency (cross-currency carts are rejected — see gap #04). Confirm no mandate is expired. Hash the PaymentMandate JWS and insert into 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.

Eight gaps the AP2 spec leaves to you

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.

#01

JWT `exp` is seconds-since-epoch. JavaScript will bite you.

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.

#02

The CartMandate is signed by the MERCHANT, not the agent.

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.

#03

Mandates reference their parent by the parent JWS itself, not by an ID.

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.

#04

The spec doesn't tell you which FX rate to use for cross-currency carts.

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.

#05

`kid` in the JWS header is optional. Plan for that.

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`.

#06

did:web resolution is your SSRF surface. Lock it down.

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.

#07

Partial settlement is undefined.

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.

#08

The reference implementation is in Go and assumes a central signing service.

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`.

Replay protection: hash the JWS, scope to the account

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.

Key rotation without breaking historical mandates

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.

What Google got right

  • JWS over JSON, not a bespoke wire format. Every language has a JWS library. We could have shipped in any stack.
  • The three-mandate split with two signers. Forcing the merchant to sign the cart and the payer to sign intent + payment maps cleanly onto how transactions actually happen. The temptation to collapse them into one would have made audit incomprehensible.
  • Inlined parent JWS instead of by-reference IDs. Verification is self-contained — no second round-trip to fetch a parent mandate from someone else's database.
  • did:web for key discovery. Web-native, scales infinitely, no certificate-authority politics. Whoever owns the domain owns the trust root.
  • Rail-agnostic. AP2 doesn't care if you settle on Visa or USDC or ACH. That's the right layer.

What's still unfinished in v0.1

  • FX rate snapshotting is undefined. Cross-currency carts are a covertly exploitable surface until the spec normates a snapshot rule.
  • Partial settlement is undefined. A real protocol has to handle "merchant captured $97 of a $100 authorization." AP2 doesn't say a word.
  • did:web hardening isn't called out. The spec mentions resolution; it doesn't tell you that the resolver is your SSRF surface.
  • The reference implementation leaks architectural opinion. Following the Go reference too literally forces you into a centralized signing-service shape that doesn't fit wallet-side or edge signing.
  • No machine-readable test vectors at v0.1. Hand-crafting your own conformance suite (which we did) is fine; the protocol would be healthier if everyone tested against the same fixtures.

Should you implement AP2 yourself?

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.

Frequently asked

Is AP2 a payment rail or an authorization protocol?+

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.

How is AP2 different from x402 or ACP?+

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.

Do I need to be on Google Cloud to use AP2?+

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.

Who else has shipped AP2 in production?+

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.

What's the production endpoint?+

`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`.

What's AgentWallet's AP2 implementation stack?+

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).

Want AP2 without writing it yourself?

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