Online payments assume the person pressing the button meant to pay. An AI agent breaks that assumption: it assembles the cart, picks the instrument and may buy while the user sleeps. Every downstream party needs to know what it cannot see: that a real user authorized this, that the authorization covered exactly this purchase, and who answers if something goes wrong.
The Agent Payments Protocol (AP2) answers those questions with signed, verifiable credentials called mandates, passed between a fixed set of roles. This article walks through that architecture as a system you would build: who signs or checks what, how one hash ties the checkout to the payment, how the human-present and autonomous flows differ, and where receipts close the loop.
Which version this describes
AP2 is young and has already changed its vocabulary. This article follows the AP2 v0.2 documentation published at ap2-protocol.org, as read on 2026-10-01. That version describes two mandate kinds, a Checkout Mandate and a Payment Mandate, each in an open and a closed form, encoded as SD-JWT credentials. Earlier material, including the mandate deep dive on this site, uses the original names: an intent mandate, a cart mandate and a payment mandate. Loosely, the old intent mandate played the part of the open mandates, and the cart mandate that of the merchant-signed checkout plus the closed Checkout Mandate, but fields and signing rules differ, so do not port code by renaming types. Pin the version you implement.
AP2 is an authorization and evidence layer inside a surrounding commerce protocol; the docs describe compatibility with the Universal Commerce Protocol (UCP). Capture, refunds and settlement stay with the commerce protocol and the payment rail. AP2 does not move money.
The roles, and the one that must not be an agent
The specification names five roles, defined by what each provides and verifies; one company can play several.
| Role | Job | Verifies | May be an LLM? |
|---|---|---|---|
| Shopping Agent (SA) | discovers products, builds the checkout, carries mandates between parties | nothing it can be trusted for; it is the party the others check | expected to be |
| Trusted Surface (TS) | shows the mandate content to the user, authenticates them, records consent, signs | the user's identity | no, must be non-agentic |
| Credential Provider (CP) | holds the user's payment credentials and issues a credential scoped to this checkout | the Payment Mandate | may be |
| Merchant (M) | offers and completes the checkout, signs the checkout JWT | the Checkout Mandate against its own cart | may be |
| Merchant Payment Processor (MPP) | submits the payment to the network | the Payment Mandate and its binding to the checkout | may be |
The network and issuer sit behind the processor and also receive the Payment Mandate and receipt. In a typical card deployment the Credential Provider is a wallet or the issuer's tokenization service, the Merchant Payment Processor is the acquirer or payment service provider, and the issuer authorizes as it does today. That mapping is a common deployment, not text from the spec.
The rule that matters most is the Trusted Surface. The docs require it to be non-agentic: deterministic code, not a model, renders what the user approves and produces the signature. If an LLM decided what the user saw or what got signed, a prompt-injected product page could change both. Treat everything the agent hands you as a claim to verify.
Two mandates, each open or closed
A Checkout Mandate authorizes what is bought; a Payment Mandate authorizes how it is paid for. A closed mandate covers one finalized checkout. An open mandate records constraints within which an agent may later create closed mandates itself. The vct claim carries the type and schema version.
| Mandate | vct | Key content |
|---|---|---|
| Closed Checkout | mandate.checkout.1 | checkout_jwt (the merchant-signed checkout), checkout_hash, iat, exp |
| Open Checkout | mandate.checkout.open.1 | constraints such as checkout.allowed_merchants and checkout.line_items; cnf holding the agent's public key |
| Closed Payment | mandate.payment.1 | transaction_id (the checkout hash), payee, payment_amount in minor units, payment_instrument, optional risk_data |
| Open Payment | mandate.payment.open.1 | constraints such as payment.amount_range, payment.budget, payment.allowed_payees, payment.allowed_payment_instruments, payment.agent_recurrence, payment.execution_date |
Allowed merchants, instruments and items are selectively disclosable in SD-JWT, so a merchant can verify it is on the list without learning the rest.
The binding: one checkout, one hash
The merchant signs a JWT describing the checkout. The closed Checkout Mandate carries that JWT and its hash; the closed Payment Mandate carries the same hash in transaction_id. The hash uses the SD-JWT's algorithm from its _sd_alg claim, or SHA-256 when absent. Because both mandates commit to the same merchant-signed bytes, nobody can pair a payment authorized for one basket with another basket.
import base64
import hashlib
HASHES = {"sha-256": hashlib.sha256, "sha-384": hashlib.sha384, "sha-512": hashlib.sha512}
def b64url(data: bytes) -> str:
# Assumption: unpadded base64url, the JOSE/SD-JWT convention. Confirm against the spec examples.
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def checkout_hash(checkout_jwt: str, sd_alg: str | None) -> str:
"""Hash of the merchant-signed checkout JWT, as it appears in the closed mandates.
The docs say the algorithm follows the SD-JWT's _sd_alg claim, or sha-256 if absent.
Unknown algorithms fail closed rather than falling back silently.
"""
alg = (sd_alg or "sha-256").lower()
if alg not in HASHES:
raise ValueError(f"unsupported hash algorithm {alg}")
return b64url(HASHES[alg](checkout_jwt.encode("ascii")).digest())
def bound_to_same_checkout(closed_checkout: dict, closed_payment: dict, sd_alg: str | None) -> bool:
expected = checkout_hash(closed_checkout["checkout_jwt"], sd_alg)
return (closed_checkout["checkout_hash"] == expected
and closed_payment["transaction_id"] == expected)Hash the exact string you received, never a re-serialized copy. And the merchant should confirm the checkout JWT is one it signed and still the cart it will fulfil; if prices changed after signing, reject rather than re-price.
The human-present flow, hop by hop
- The agent assembles a cart with the merchant, which creates and signs the checkout JWT. The agent fetches instrument options from the Credential Provider and picks one.
- The Trusted Surface shows both mandates' content to the user, authenticates them and, on consent, signs both closed mandates with the user's key, linked by the checkout hash.
- The Credential Provider verifies the closed Payment Mandate and returns a payment credential scoped to that checkout.
- The merchant receives the credential and the Checkout Mandate, verifies the mandate against its cart and starts the payment with the credential and the hash.
- The Merchant Payment Processor verifies the Payment Mandate and the binding, then submits the payment.
- The processor signs a Payment Receipt for the agent, the Credential Provider and the network; the merchant signs a Checkout Receipt for the agent.
The agent never holds a reusable card number, only a credential minted for one checkout, which limits what a compromised agent can steal; see tokenization.
The human-not-present flow: delegation to an agent key
The autonomous flow splits consent from execution. While the user is present, the Trusted Surface signs open mandates with the user's key, for example 'up to 60 euros a week at these two grocers'. The open mandates carry the agent's public key in a cnf claim, so only the holder of the matching private key can use them.
Later, with the user gone, the agent receives a signed checkout, picks open mandates whose constraints fit, and creates the closed mandates itself, signed with its own key and linked to the checkout hash and back to the open mandate. Verifiers receive both forms and check the user's signature on the open mandate, the agent's on the closed one, the key binding, and that the closed mandate falls inside the constraints.
Per-transaction constraints like an amount range can be checked from the mandates alone. Cumulative ones, a budget or a maximum number of occurrences, need a running total held by a verifier with its own storage, typically the Credential Provider, never taken from the agent's request.
from dataclasses import dataclass
@dataclass
class Usage: # held by the verifier, never taken from the agent's request
spent_minor: int # total already spent under this open mandate, minor units
uses: int # closed mandates already accepted under it
def check_closed_payment(closed: dict, open_: dict, usage: Usage, now: int) -> list[str]:
"""Deterministic check of a closed Payment Mandate against its open mandate's constraints.
Signatures, key binding (cnf) and sd_hash must already have been verified with your
SD-JWT library. The property names inside each constraint are illustrative: map them
from the published schema for the version you implement.
"""
problems = []
amount = closed["payment_amount"] # {"amount": int minor units, "currency": "EUR"}
if closed.get("exp") is not None and now >= closed["exp"]:
problems.append("closed mandate expired")
if open_.get("exp") is not None and now >= open_["exp"]:
problems.append("open mandate expired")
for con in open_.get("constraints", []):
kind = con["type"]
if kind == "payment.amount_range":
if amount["currency"] != con["currency"]:
problems.append("currency outside mandate")
elif not con.get("min", 0) <= amount["amount"] <= con["max"]:
problems.append("amount outside range")
elif kind == "payment.allowed_payees":
if closed["payee"]["id"] not in {m["id"] for m in con["allowed"]}:
problems.append("payee not allowed")
elif kind == "payment.allowed_payment_instruments":
if closed["payment_instrument"]["id"] not in {i["id"] for i in con["allowed"]}:
problems.append("instrument not allowed")
elif kind == "payment.budget":
if usage.spent_minor + amount["amount"] > con["max"]:
problems.append("budget exhausted")
elif kind == "payment.agent_recurrence":
if con.get("max_occurrences") is not None and usage.uses >= con["max_occurrences"]:
problems.append("occurrences exhausted")
# payment.execution_date, payment.reference and payment.allowed_pisps are defined by
# the spec too: handle them before production, or valid mandates are rejected here.
else:
problems.append(f"unknown constraint {kind}") # fail closed on what you do not understand
return problemsThe checker fails closed on constraints it does not recognise. When a new constraint type appears in a later schema, a verifier that silently ignored unknown constraints would approve a purchase the user had limited.
Receipts and reconciliation
Every flow ends with two signed receipts. The merchant's Checkout Receipt carries a status, a reference (the hash of the closed mandate) and on success an order_id. The processor's Payment Receipt carries a status, a reference, a payment_id and on success the processor's and network's confirmation identifiers. These are the join keys across the agent's log, the merchant's orders, the processor's payments and the settlement file.
-- One row per attempted agent purchase, written by the merchant's payment service.
CREATE TABLE agent_payment (
checkout_hash TEXT PRIMARY KEY, -- = closed Payment Mandate transaction_id
order_id TEXT, -- from the Checkout Receipt
payment_id TEXT, -- from the Payment Receipt
psp_confirmation_id TEXT, -- present on success
network_confirmation_id TEXT, -- present on success
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
mode TEXT NOT NULL, -- 'human_present' or 'human_not_present'
checkout_mandate TEXT NOT NULL, -- the SD-JWTs exactly as received, for disputes
payment_mandate TEXT NOT NULL,
receipt_status TEXT NOT NULL
);
-- Daily break report: settled lines from the acquirer that have no mandate evidence.
SELECT s.*
FROM settlement_line s
LEFT JOIN agent_payment a ON a.network_confirmation_id = s.network_ref
WHERE s.channel = 'agent' AND a.checkout_hash IS NULL;Store the mandates exactly as received: in a dispute the docs describe recomputing the checkout hash and matching the receipt's reference against the closed mandate's hash. Matching and breaks are covered in reconciliation; the checkout hash is also a natural key for idempotent payment retries.
Worked example: a weekly grocery agent
Mira approves, on her phone's trusted surface, an open Checkout Mandate allowing two grocers and an open Payment Mandate with an amount range of 0 to 60 euros, a budget of 250 euros, weekly recurrence with at most four occurrences, and one allowed card. The agent's public key is in cnf.
Week one: the agent builds a 48.20 euro basket, receives the signed checkout and signs closed mandates with transaction_id set to its hash. The Credential Provider verifies both signatures, checks the range and its own ledger (0 spent, 0 uses), mints a scoped credential and records 48.20 against the budget. Merchant and processor verify their parts; receipts return an order id and payment id.
Week three: the basket is 63.10 euros, fails the amount range, and the agent must return to Mira; no use is recorded. Weeks one, two, four and five succeed, spending about 193 euros. Week six would be a fifth accepted order, so it fails the occurrence limit although about 57 euros of budget remain. The agent should report back, not split the basket to fit under the range; a verifier cannot tell two honest baskets from one split basket, so that rule belongs in the agent's policy.
Failure modes
- Agent-rendered consent. The model's UI shows the approval, so injected content changes what the user sees.
- Hashing re-serialized JSON. Hashes differ and legitimate payments fail, or a verifier compares two values it computed itself and checks nothing.
- Cumulative limits held by the agent. A budget in agent memory resets on a crash. Keep it in the verifier's database, updated atomically with issuing the credential.
- Unknown constraints ignored. A verifier on an old schema approves outside a newer limit.
- Evidence discarded. Only an order row survives, so a dispute cannot be answered with the signed mandates.
- Agent key compromise. An open mandate bound to a stolen key is spendable up to its limits; keep open mandates short-lived and narrow. See the threat model.
Trade-offs
Broad open mandates mean fewer prompts and a larger blast radius; tight amount ranges catch mistakes but return to the user whenever prices move. Selective disclosure protects privacy but no party sees the whole picture, so design logs around receipt references. Verification hops add latency to checkout, so cache keys and trust lists and measure each verifier.
What to do next
- Read the current checkout and payment mandate pages and record the version and vct values you support.
- List the roles you play, and for each the mandates you verify and the evidence you store.
- Implement the checkout hash once, tested against the spec's examples.
- As a Credential Provider, keep cumulative counters in a transactional store and fail closed on unknown constraints.
- Build the Trusted Surface as deterministic code that renders exactly the bytes it signs.
- Add the reconciliation table and a daily break report.
- Run the AP2 repository's human-present and human-not-present samples end to end.