Agent and AI engineering · Practical technical guide · 2 November 2026
When the total changes between the merchant signing a cart and the payment, the signed cart no longer matches. How AP2 binds payment to the signed checkout hash, when to re-sign and re-confirm, and why a silent rupee tolerance fails.
A cart signed at one total, charged at another
Jenya asks her shopping agent to buy three books from Belgavi's Book Store: The Monsoon Atlas at ₹499, Kannada for Beginners at ₹350 and Stars over Hampi at ₹650. With ₹49 shipping and ₹8.82 tax on shipping the merchant signs a cart for ₹1,556.82, and the payment mandate is built for that cart. Before the charge goes through, the store recomputes shipping for the final delivery address: ₹40 more, plus ₹7.20 more tax on shipping. The new total is ₹1,604.02.
Nothing about this is fraud: prices update, shipping and tax are recomputed, a book is substituted, a total gets rounded. What should happen to a mandate signed for a different cart? This article covers what the signature binds, what must happen when the cart moves, and why quietly accepting small differences undoes the protection, especially when no human is watching.
What the merchant signature and the payment mandate bind
In AP2 v0.2.0 (released 28 April 2026; main branch at commit e1ea56d, read on 2 November 2026), the merchant signs the checkout as a JWT, checkout_jwt. The closed Checkout Mandate carries that JWT and a checkout_hash: the base64url-encoded hash of the checkout_jwt value, SHA-256 unless the SD-JWT names another algorithm in _sd_alg. The closed Payment Mandate (vct mandate.payment.1) carries a transaction_id defined as the same hash, plus payee, payment_amount in integer minor units (paise for INR) and payment_instrument. So the payment is tied to one specific signed checkout, not to an amount.
Two details matter here. The checkout JWT must be signed with a non-deterministic scheme such as ECDSA, not Ed25519, to prevent rainbow-table attacks, so even re-signing an identical cart gives a new hash: the hash identifies one signed instance. And the merchant verifies the Checkout Mandate against the current cart state before completing it: if the cart it would now charge is not the one whose hash the mandate carries, verification fails.
In the earlier v0.1.0 models, a CartMandate held contents (a CartContents with an id, a W3C PaymentRequest, cart_expiry and merchant_name) and a merchant_authorization JWT whose payload included a cart_hash computed over the canonical JSON of the cart contents. The user authorization on the PaymentMandate signed over hashes of the cart mandate and the payment mandate contents.
A one rupee change, hashed
The v0.1 model says "canonical JSON" without naming a canonicalization scheme, so the two sides of a hash comparison have to agree on one. The snippet below uses sorted keys, no whitespace, ASCII escaping and amounts as integer paise, which keeps floating-point formatting out of the bytes being hashed. It hashes Jenya's cart, then the same cart with one book ₹1 dearer.
Runnable with Python 3.11 or newer, standard library only; the comments show its output.
import base64
import copy
import hashlib
import json
cart = {
'cart_id': 'CART-7731',
'merchant': "Belgavi's Book Store",
'buyer': 'Jenya',
'currency': 'INR',
'line_items': [
{'sku': 'BK-2101', 'title': 'The Monsoon Atlas', 'qty': 1, 'unit_price': 49900},
{'sku': 'BK-2102', 'title': 'Kannada for Beginners', 'qty': 1, 'unit_price': 35000},
{'sku': 'BK-2103', 'title': 'Stars over Hampi', 'qty': 1, 'unit_price': 65000},
],
'shipping': 4900,
'tax': 882,
'total': 155682, # paise: Rs 1,556.82
}
def canonical(obj):
return json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('utf-8')
def cart_hash(obj):
return hashlib.sha256(canonical(obj)).hexdigest()
signed = cart_hash(cart)
changed = copy.deepcopy(cart)
changed['line_items'][0]['unit_price'] += 100 # Rs 1 more for one book
changed['total'] += 100
print('signed ', signed)
print('changed', cart_hash(changed))
print('equal ', signed == cart_hash(changed))
digest = hashlib.sha256(canonical(cart)).digest()
print('b64url ', base64.urlsafe_b64encode(digest).rstrip(b'=').decode())
# signed 2b5f0a41451283732d64bce8053da7810ebc2274b0c5b4d5f4cfc23499cb1a74
# changed b6390214469e49ca3aab715686a7e991f485f27ebc73bbf1ac37ee648e0a1f6b
# equal False
# b64url K18KQUUSg3MtZLzoBT2ngQ68InSwxbTV9M_CNJnLGnQA one rupee change produces an unrelated digest: the hash says that something changed, not how much. The last line shows the base64url form that v0.2 uses for checkout_hash. In v0.2 the input is the merchant-signed JWT string rather than canonical cart JSON, so you hash exactly the bytes the merchant signed and canonicalization drops out of the problem.
Four ways a total moves after signing
| Change | Hash changes | Total changes | What it needs |
|---|---|---|---|
| Price update on one book | yes | yes | a newly signed cart and new consent |
| Shipping or tax recomputed for the address | yes | yes | collect the address before signing; otherwise re-sign |
| Out-of-stock book substituted | yes | maybe not | new consent: the items differ even at the same total |
| Total rounded to whole rupees | yes | by paise | sign the rounded total in the first place |
The shipping row has a structural fix: the v0.1 A2A extension profile says a merchant agent must not produce a CartMandate until all payment-impacting information has been collected, and gives the shipping address as the example. Ask for the address first and sign once. The substitution row is the one that totals-only checks miss: same amount, different book.
Re-sign, then re-confirm the right way for the mode
When the cart moves, the only correct merchant behaviours are to reject the stale mandate with an error receipt, or to sign the new cart and ask for a new mandate that references the new hash. Which consent that new mandate needs depends on the mode.
In human-present mode the user approves the closed checkout and payment directly, so the trusted surface shows Jenya the new cart and the difference, and she signs again. In human-not-present mode the user has signed open mandates with constraints, and the agent signs closed mandates with its own key. The agent may approve the new cart only if it still satisfies those constraints: a payment.amount_range whose max is an integer in paise, and a checkout.line_items list of acceptable items. If the new total exceeds the range or a substituted SKU is not in the list, the verifier returns unresolved_constraint, which the specification describes as a signal to fall back to a directly approved closed mandate, bringing the user back. Because an agent must not present an open mandate again until it holds a rejection receipt for the previous presentation, the stale presentation is first closed by an error Checkout Receipt; only then does the agent sign for the new hash.
Why silently accepting a small difference is dangerous
A common shortcut is a rule such as "accept the new total if it is within ₹50 of the approved amount". It breaks three things.
First, nobody signed it. The user-signed amount_range is a tolerance the user consented to, and even then the agent still signs a closed mandate that binds the new checkout hash. A verifier-side allowance is consent the system granted itself. In human-not-present mode there is no person to notice the extra ₹47.20 at the time.
Second, it compares the wrong thing. An amount check passes a substitution at the same price, so Jenya receives a paperback reprint she never chose, and the payment record says everything matched.
Third, it breaks the evidence. At dispute time the specification requires the hash of the checkout_jwt to be recomputed and checked against the Checkout Mandate, and the Payment Mandate to be verified using that hash. A charge made for a cart whose hash no mandate carries has no consistent chain to show, which is the opposite of what the mandates were for.
Failure modes to test for
- Hashing different bytes on each side. Hash the signed JWT string exactly as received, never re-serialized JSON.
- Three ways to write ₹1,556.82. The W3C Payment Request API types an amount value as a decimal string, the AP2 v0.1 model as a float, v0.2 as integer paise. Convert once, at the edge, or the same cart hashes differently depending on who rounded.
- Signing before the address. Every shipping or tax recompute after signing becomes a re-confirmation round trip.
- Retrying with the old mandate. An agent that resends the original Payment Mandate after a merchant re-sign gets a stale-cart rejection loop.
How to verify it
- Unit-test that any change to a signed cart, including a substitution at the same total, changes the hash and is rejected by the verifier.
- For every payment, assert that the charged amount equals the
payment_amountof the mandate whosetransaction_idmatches the charged checkout. Exceptions should be zero, not small. - Count
unresolved_constraintfallbacks and stale-cart rejections per merchant; a rising count usually means signing before the address is known. - Replay a dispute: recompute every hash from the stored JWTs.
The companion lab in numbers
The lab replays Jenya's three-book order through a deterministic model, with a real synchronous SHA-256 checked against Node's crypto module. At the defaults the shipping change raises the total from ₹1,556.82 to ₹1,604.02, the merchant charges the new total under the old mandate, and a verifier comparing amounts with a ₹50 tolerance accepts it: one stale-cart payment and one overcharge of ₹47.20 without consent. The trace shows the mandate still referencing 2b5f0a41... while the charged cart hashes to 5faf94a0.... Turning on the hash check rejects the payment. Lowering the tolerance to ₹40 also rejects it, but only because of the amount. Re-signing in human-present mode charges ₹1,604.02 after Jenya re-confirms; in human-not-present mode the agent approves it alone with an amount_range max of ₹1,650 and sends it back to Jenya at ₹1,600. A same-price substitution with zero tolerance is still accepted as a stale cart by the amount-only check, and rounding to whole rupees adds 18 paise that a ₹1 tolerance waves through.
Sources and scope
Checked against these sources on 2 November 2026. The Python snippet was run with Python 3.13 and its output is shown; lab figures come from running the lab model, whose SHA-256 is tested against Node crypto. No payment system was called.
- AP2 specification v0.2 (ap2-protocol.org)
- AP2 v0.2.0: Checkout Mandate (commit e1ea56d)
- AP2 v0.2.0: Payment Mandate and constraints (commit e1ea56d)
- AP2 v0.2.0: Agent Authorization, receipts and errors (commit e1ea56d)
- AP2 v0.2.0: flow examples (commit e1ea56d)
- AP2 v0.1.0: mandate models and data keys (mandate.py)
- AP2 v0.1.0: A2A Extension for AP2 (v0.1-alpha)
- W3C Payment Request API
An AP2 payment is bound to the hash of one merchant-signed checkout, so any change after signing, even ₹1 or a same-price substitution, makes the old mandate stale. Collect price-affecting details before signing, and when the cart moves, re-sign and get new consent: the user in human-present mode, the open mandate constraints or an unresolved_constraint fallback otherwise. Never accept a total within a silent tolerance nobody signed.