Engineering research / ap2

AP2 Price Changes After Signing: Re-Confirming When the Cart Total Moves

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.

By Sandeep Belgavi · Published 2026-11-02

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_CNJnLGnQ

A 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

ChangeHash changesTotal changesWhat it needs
Price update on one bookyesyesa newly signed cart and new consent
Shipping or tax recomputed for the addressyesyescollect the address before signing; otherwise re-sign
Out-of-stock book substitutedyesmaybe notnew consent: the items differ even at the same total
Total rounded to whole rupeesyesby paisesign 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

  1. 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.
  2. For every payment, assert that the charged amount equals the payment_amount of the mandate whose transaction_id matches the charged checkout. Exceptions should be zero, not small.
  3. Count unresolved_constraint fallbacks and stale-cart rejections per merchant; a rising count usually means signing before the address is known.
  4. 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.

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.

Views are my own and do not represent any employer, past or present.