Skip to main content
A mandate becomes a live spending authorization only when a customer-controlled signer signs it. This page defines the exact bytes that get signed, so any client — browser passkey, server-held key, hardware signer — can produce a verifiable signature.

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:
Every field comes from the mandate as returned by 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".
On success the mandate flips 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_until has 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

Same payload with "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.
Dual control applies exactly as it does to approve and cancel: the signer must be a recognized signer of the mandate’s customer other than the bound one.

The amend payload

Two differences from the approve/cancel payload, both load-bearing:
  • rule is 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.
  • version is the version being created — the mandate’s current version + 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 from GET /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.
Raw 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 the signature, 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

  1. GET /mandates/{mandate_id} — read id, bound_signer_id, rule, valid_from, valid_until, and (for an amendment) version.
  2. 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).
  3. Sign with a recognized signer key that is not the bound signer (or run a WebAuthn assertion with the payload as challenge).
  4. POST .../approve, .../cancel or .../amend with the public key + base64 signature (amend also sends the rule itself).
  5. Confirm the response — active means the mandate can now cover payments at fire time; an amendment returns the incremented version.
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.