Dual control
Every mandate binds one signer — a hosted payment agent’s, or any signer named directly at creation (Direct Control). Approving, and later cancelling, requires a recognized signer of the mandate’s customer other than the bound one:- The bound signer can never activate its own authority — a second identity must sign.
- The same rule applies to cancellation: the bound signer cannot mutate its own mandate, not even to reduce it.
- “Recognized” means the signer is in the signer group of a wallet the customer controls — the same recognition model as endorsed requests.
The canonical payload
The signature covers the RFC 8785 (JCS) canonical JSON of exactly these fields:GET /mandates/{mandate_id} — a client reproduces the payload from the API response alone.
Why byte-exactness matters: the platform verifies your signature against its own canonicalization of the same fields. JCS guarantees that both sides — regardless of language, struct field order, or whitespace habits — emit the identical byte sequence: keys sorted, no insignificant whitespace, standard number and string encoding. Use a JCS library (available for every mainstream language) rather than hand-rolling JSON.stringify ordering.
The action verb is inside the payload. "action": "approve", "cancel" and "amend" produce different bytes, so an approval signature can never be replayed as a cancellation, or vice versa.
amend signs a different payload shape — it carries an extra version key, and its rule is the new rule rather than the stored one. See Amend.Approve
approver_public_key— the recognized signer’s public key (identifies who is signing; must differ from the bound signer).signature— base64, over the canonical payload with"action": "approve".
pending → active. Mandates and scheduled payments are decoupled — nothing is bound at approval; any payment the rule covers is matched at fire time. Approval is refused when:
- the mandate is not
pending(already active, rejected, or revoked), - its
valid_untilhas already passed — a signature over a dead grant authorizes nothing, so the platform refuses to spend one, - its originating instruction failed part-way — such an orphan must never become live authority.
Cancel
"action": "cancel". The resulting status records when it ended:
The audit trail keeps who did what:
approved_by_signer_id, rejected_by_signer_id, revoked_by_signer_id on the mandate.
Amend
POST /mandates/{mandate_id}/amend appends a new immutable version to an active mandate, carrying a changed rule into force with one signature and without resetting the spend already made in the current window. An agent that has spent 9,000 of a 10,000 monthly limit and is amended to 20,000 has 11,000 left, not 20,000 — see Amending preserves spend.
The amend payload
Two differences from the approve/cancel payload, both load-bearing:ruleis the new rule, not the one currently stored. Every other action signs the mandate as it already exists; this one signs what it is about to become.versionis the version being created — the mandate’s currentversion+ 1. Including it is what stops a signature for v2 being replayed to create v3.
valid_from and valid_until still come from the mandate and are not amendable. 0 for both is normal — it means the mandate never expires.
The rule must already be canonical
Approve and cancel sign a rule you can read back fromGET /mandates/{mandate_id}. Amend has no such artifact: the rule does not exist yet, so there is nothing to fetch and reproduce. The platform therefore takes your rule verbatim — it stores it, and verifies your signature against it, exactly as sent — and refuses anything it would otherwise have rewritten:
Each is a
400 naming the offending field. This is deliberate: had the platform normalized instead, it would have signed and stored bytes you could not reproduce, and you would have seen mandate amendment signature invalid — an error pointing at your key when the actual problem was your JSON.
Mandate creation still normalizes: it accepts a payee name and resolves it, and defaults an absent
window. Only amend is strict, and only because only amend has no readable artifact to sign against.address targets are not affected — they are never normalized on any path, so send them exactly as you hold them. Do not lowercase them yourself: EVM checksum casing is cosmetic and matched case-insensitively, but Solana base58 addresses are case-sensitive, and folding those merges genuinely distinct addresses.
What can change
Only the amount fields (max_per_tx and the window caps) and targets.
target_type, window, asset and network_id are frozen — changing one is rejected with a 400 telling you to cancel the mandate and create a new one. window in particular is frozen because usage rows carry no window label, so changing it would retroactively re-bucket every past spend.
Adding a payee requires the resulting rule to carry max_amount_in_window or max_count_in_window. Under per-target caps alone the effective ceiling is (number of payees) × cap, so a payee could be added — raising total exposure — without any amount field changing. Removing a payee is always allowed: a guard against authority growing must never block you shrinking it.
Signing with a passkey (WebAuthn)
Browser passkeys are supported directly: pass the WebAuthn assertion as thesignature, using the canonical payload as the assertion challenge. The platform verifies that the assertion’s challenge matches the mandate payload and that the ES256 signature verifies against the enrolled passkey — standard authenticatorData ‖ SHA-256(clientDataJSON) WebAuthn semantics, no custom scheme. Server-held ES256 keys sign the payload’s SHA-256 digest directly.
Signing checklist
GET /mandates/{mandate_id}— readid,bound_signer_id,rule,valid_from,valid_until, and (for an amendment)version.- Build the payload object with the intended
action; canonicalize with a JCS (RFC 8785) library.- For
amend, use the new rule instead of the stored one, add"version": <current version + 1>, and make sure the rule is already canonical (see The rule must already be canonical).
- For
- Sign with a recognized signer key that is not the bound signer (or run a WebAuthn assertion with the payload as challenge).
POST .../approve,.../cancelor.../amendwith the public key + base64 signature (amend also sends theruleitself).- Confirm the response —
activemeans the mandate can now cover payments at fire time; an amendment returns the incrementedversion.
Getting
mandate amendment signature invalid? Check the JSON before the key. The usual causes are a rule the server would have rewritten (see the canonical-rule table above), signing the stored rule instead of the new one, or omitting version.
