@tollstile/ap2
v0.1.2
Published
Experimental: require AP2 user mandates, so an agent can only pay with a user-signed authorization bound to your quote. A Tollstile requirement.
Maintainers
Readme
@tollstile/ap2
Experimental. AP2 does not yet define how a mandate travels with an API call or MCP tool call, and several of its rules assume a merchant-signed checkout. Read AP2 gaps before relying on this in production.
userMandate() is a Tollstile requirement that admits a paid request only when it carries an AP2 v0.2 Payment Mandate chain proving that a user authorized an agent to pay this merchant at least this price.
It does not move money. A rail still verifies and settles the payment; the mandate is evidence of user authorization that must accompany it. It runs after the payment proof is verified and before anything is reserved.
Install
npm install tollstile @tollstile/ap2Example
import { createTollstile, memoryLedger, testRail } from 'tollstile';
import { paid } from '@tollstile/fetch';
import { userMandate } from '@tollstile/ap2';
const issuers = new Map<string, JsonWebKey>([['agent-provider-key-1', { kty: 'EC', crv: 'P-256', x: '…', y: '…' }]]);
const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger(), secret: process.env.QUOTE_SECRET });
export default {
fetch: paid(
toll.price('$5.00', {
require: [
userMandate({
resolveKey: async (header) => issuers.get(String(header.kid)),
payee: { id: 'merchant_1' },
audience: 'https://api.example',
}),
],
}),
() => Response.json({ ok: true }),
),
};The agent's round trip:
- Call without payment →
402whose body carriesquote(the token the rail echoes) andnonce. - Build the KB-SD-JWT for the closed mandate with
nonce= thatnonceandaud= one of your audiences. - Retry with the rail's proof carrying the quote, plus
AP2-Mandate: <open SD-JWT>~<KB-SD-JWT>~<disclosures>~(or_meta["ap2/mandate"]over MCP).
Failures are 402, rendered by core as a requirement failure the agent answers by presenting a valid mandate with a new payment:
{
"error": { "code": "requirement_failed", "retryable": true, "action": "pay", "message": "…", "detail": "mandate_invalid:nonce_mismatch" },
"requirement": "user-mandate",
"resource": "POST /purchase"
}error.detail is mandate_required or mandate_invalid:<check>, where <check> is one of the stable identifiers listed below (some carry a :<claim> or :<constraint type> suffix). Log it; branch on error.code and error.action.
Options
| Option | Default | Description |
|---|---|---|
| resolveKey | required | (protectedHeader) => Promise<JsonWebKey \| undefined>: the P-256 key of the root issuer (user credential or trusted agent provider), e.g. by kid. Validating x5c chains is up to you. |
| payee | required | { id, name?, website? }. The closed mandate's payee.id must equal id; name and website are compared when given. |
| audience | required | Accepted KB-SD-JWT aud values. |
| header | AP2-Mandate | HTTP header carrying the chain. |
| metaKey | ap2/mandate | MCP _meta key carrying the chain. |
| maxAgeMs | 300000 | Oldest KB-SD-JWT iat accepted. |
| clockSkewMs | 30000 | Tolerance for iat, exp, nbf, and execution dates. |
What is checked
The chain must be exactly an issuer-signed SD-JWT and one KB-SD-JWT, joined by ~~ as the AP2 SDK serializes them.
- Root.
algisES256; the signature verifies withresolveKey(header);typis not a key-binding type. Disclosures are resolved per RFC 9901 §7.1 (_sddigests and{"...": digest}array elements, recursively, with_sd_algsha-256/384/512); a malformed, duplicated, or unreferenced disclosure fails.delegate_payloaddiscloses exactly one object, whosevctis exactlymandate.payment.open.1, withconstraintsandcnf.jwk. - Key binding.
typiskb+sd-jwt; the signature verifies with the open mandate'scnf.jwk; exactly one ofsd_hash(over the root SD-JWT including disclosures and trailing~) orissuer_jwt_hash(over the root JWT) matches;iat,aud, andnonceare present. - Closed mandate. One disclosed object with
vctexactlymandate.payment.1, nocnf,transaction_id,payee {id, name},payment_amount {amount: integer minor units, currency},payment_instrument {id, type}. - Times.
exp,nbf, andiaton both tokens and both mandates; KBiatwithinmaxAgeMs; anexecution_datemust not be in the future. - Binding to this request.
audis a configured audience. When the payment carried a Tollstile quote,nonceequalsquote.nonce. - User intent. Every claim the open mandate pre-sets (other than
vct,constraints,cnf,iat,exp,nbf) is equal in the closed mandate. Constraintspayment.amount_range,payment.allowed_payees(AP2 SDK matching), andpayment.execution_date(absentexecution_datemeans now) must hold; any other constraint fails. - Merchant terms.
payeematchespayee;payment_amountis in the price's currency and, converted with ISO 4217 minor units, at least the price. - Single use. The SHA-256 of the KB-SD-JWT signing input is claimed until the key binding could no longer be accepted; a second presentation is
mandate_reused.
<check> values include issuer_untrusted, issuer_signature_invalid, kb_signature_invalid, kb_binding_missing, kb_binding_mismatch, disclosure_unreferenced, open_mandate:vct_mismatch, closed_mandate:payment_amount_invalid, audience_mismatch, nonce_mismatch, stale, expired, preset_mismatch:<claim>, constraint_failed:<type>, unsupported_constraint:<type>, payee_mismatch, amount_insufficient, currency_mismatch, key_binding_required, delegation_depth_unsupported, and mandate_reused.
Behavior to know
- Quote nonce. AP2 expects the verifier to issue the key-binding nonce. Tollstile's quote is that nonce: it is signed, expires, and is bound to the route, so a mandate made for one 402 cannot be presented against another quote. Without a quote (a rail that does not echo one, or a fixed price paid without it), any
nonceis accepted and replay protection rests onmaxAgeMsand the single-use claim. - Direct mandates are refused (
key_binding_required). A Human Present Payment Mandate signed directly by the user has noaudornonce, so nothing binds it to this verifier. - Delegation beyond one agent (
kb+sd-jwt+kbhops) is refused; AP2 v0.2 places agent-to-agent delegation out of scope. - Claims and retries. The mandate is claimed before the charge is created. If the handler then fails and the charge is released, the same mandate cannot be presented again; the agent requests a new quote and signs a new key binding.
- Mandate receipts (a signed Mandate Receipt JWT the verifier returns) are not produced.
AP2 gaps
These are open issues in AP2 v0.2 (google-agentic-commerce/AP2 @ e1ea56d) for per-call API payments, and how this package handles them:
- No carrier. AP2 defines no HTTP header, x402 field, or MCP
_metakey for mandates.AP2-Mandateandap2/mandateare this package's conventions, configurable viaheaderandmetaKey. - No per-call checkout.
transaction_idand thepayment.referenceconstraint assume a merchant-signed checkout JWT and an open Checkout Mandate. The open mandate schema requirespayment.reference, so fully schema-conformant open mandates are rejected here withunsupported_constraint:payment.referenceuntil a per-call equivalent exists.transaction_idis required but not checked. - Sub-cent prices.
payment_amountis integer minor units, so a$0.001call needs a mandate of at least$0.01. - Mandate ↔ payer binding. Nothing in AP2 binds the mandate to the rail's payer (wallet, card token); the AP2 x402 sample uses a keccak256 nonce, which is not specified. This package does not compare the mandate with the rail payer.
- Verifier nonce. Closed here by the Tollstile quote nonce (above); AP2 itself has no nonce round trip for a single-request 402 flow.
- Receipts. AP2 requires a signed Mandate Receipt; there is no verifier key or response carrier for one here.
- Root trust. User credential
x5cchains versus trusted agent providerkids are deployment policy, delegated toresolveKey. - Stateful constraints.
payment.budgetandpayment.agent_recurrenceneed a history of presentations (and the AP2 SDK's budget units disagree withamount_range), so they fail as unsupported.
Verification status
Tested with Vitest (Node 22 WebCrypto) against:
- The encoded open → closed chain in AP2
docs/ap2/payment_mandate.md:sd_hash(uixoHemm…PK0Ck), disclosure digests (including the payee nested inallowed_payees), the KB-SD-JWT signature under the open mandate'scnf.jwk, tamper detection, and the amount. The root signature cannot be checked because the example'sagent-provider-key-1is generated at runtime and not published; the full chain therefore fails closed withissuer_signature_invalid, and itspayment.referenceconstraint is reported as unsupported. - Generated ES256 chains through
createTollstilewithtestRail(): the 402 → mandate bound to the quote nonce → 200 round trip, wrong nonce, audience, issuer, and agent keys, both binding modes, tampered disclosures,vctmismatches, amounts, currencies and ISO 4217 minor units, payee, each supported constraint, pre-set values, expiry and staleness, reuse, direct and deeper chains, malformed input, and the HTTP header and MCP meta carriers.
Not verified against chains produced by the AP2 Python SDK or any credential provider. To verify: run the AP2 SDK (code/sdk/python) to create an open Payment Mandate with only amount_range / allowed_payees / execution_date constraints and cnf set to an agent key, then a closed mandate with kb_sd_jwt.create(..., aud=<your audience>, nonce=<nonce from the 402 body>), join with ~~, and send it in AP2-Mandate alongside a real rail payment. Expect 200, and 402 mandate_invalid:mandate_reused on a second send.
