Skip to main content
Private preview: this is rolling out and may not be available on your account yet.
A person signs a mandate. An agent can then be given a smaller mandate narrowed from it, for example to make one purchase against a larger spending mandate. The smaller mandate’s token is bound to the agent’s own key, and it names every larger mandate it was narrowed from, back to the one the person signed. The offline check tells you that token is genuine. This page covers the two checks a narrowed mandate adds:
  • The chain. Every larger mandate it names was signed, unaltered, and the one at the top was signed by the person.
  • The key. Whoever presents the token holds the key it is bound to.
You can run both with the sumvin-verify Python package, or implement them yourself from this page.

What a narrowed mandate’s token carries

A narrowed mandate’s token carries every claim the offline check reads, plus two more. A mandate the person signed directly carries neither.
The digests make the chain checkable without trusting whoever serves it. The token is signed by Sumvin, so a signed request that hashes to the digest it names is the one the mandate was narrowed from.

What you need

The x-sumvin-mandate value is the unpadded base64url encoding of this JSON, with keys sorted and no whitespace:
payload is the narrowed mandate’s own signed request, as EIP-712 typed data. Pass the header value on unchanged: Sumvin compares the signed request and the signature with the ones it stored, exactly, and re-encoding the typed data can change it.

Fetch the larger mandates

$JTI is the token’s jti claim. No API key is needed: the route answers only a request that presents the signed request stored for that token. A mismatched pair returns the same 404 as a token that doesn’t exist, and a header that can’t be decoded returns 400. The route is rate limited per IP address and per token; past the limit it returns 429 with Retry-After. Answers are never cached.
Don’t trust any of this because Sumvin served it. Every check below recomputes it from the token and the signatures.

The checks, in order

A failure at any step is a refusal.
1

Check the token

Run the offline check: ES256 against Sumvin’s key set, the issuer https://sis.sumvin.com, your audience, and expiry. Then require cnf and parent. A token without them is not a narrowed mandate.
2

Check the narrowed mandate's own signed request

Decode x-sumvin-mandate. Its message.resources must include parent.uri, and its message.wallet must equal the token’s wallet. Its signature must verify either as an agent signature against the token’s cnf, or as the person’s signature.
3

Match the larger mandates to the token

Fetch the ancestors. There must be exactly one for each level of parent, and each one’s pint_uri must equal that level’s uri.
4

Recompute every digest

Compute the EIP-712 signing hash of each ancestor’s typed_data: keccak256(0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message)), using the typed data’s own types and domain. It must equal that level’s digest. A mismatch means the signed request was altered, or is not the one the mandate was narrowed from.
5

Check that each mandate names the one above it

Every ancestor except the last must include the next ancestor’s pint_uri in its message.resources.
6

Check every signature

An ancestor with signed_by: agent must verify as an agent signature against the cnf the token gives for that level. If the token gives that level no cnf, refuse. The last ancestor must be signed_by: user and verify as the person’s signature. A chain whose top was signed only by an agent has no signature from the person, so refuse it.
Passing every step tells you the person signed the mandate at the top, each mandate below it was narrowed from the one above, nothing was altered, and the narrowed mandate was signed by its bound key or by the person. Whether it still stands is a separate question: run the website token check.

The person’s signature

The person signs with a secp256k1 key. The signature is 65 bytes, r ‖ s ‖ v, with v either 27 or 28. Refuse any other v.
  1. Recover the signing address from the digest.
  2. If it equals message.wallet, the person’s own key signed. Accept.
  3. Otherwise the wallet must be a Safe the person’s key owns. Require domain.verifyingContract to equal message.wallet. Then call getOwners() (selector 0xa0e67e2b) on that address, with eth_call on the chain domain.chainId names. Accept only if the recovered address is one of the owners.
Check that your node serves the chain the mandate names before you trust its answer.

Agent signatures

An agent key signs the way a passkey does, so the same signature can be checked on chain. Its signature is 65 bytes, r ‖ s ‖ 0x00. The final byte is a fixed placeholder, not a recovery id. The rest of what was signed is fixed, so you rebuild it from the digest:
client_data_json is exactly those bytes, with no whitespace. 0x05 means the user was present and verified, and the counter is always zero. Verify ECDSA P-256 with SHA-256 over the signed message, using r and s against the cnf key’s x and y. s is always in the lower half of the curve order, so a verifier that insists on low-s accepts it.

Check the agent holds the key

The token alone doesn’t prove the presenter is the agent it was issued to. Ask for a proof made with the bound key, for you, now:
  1. Issue a nonce for this request: a random, single-use value you generate, at least 128 bits, for example 32 hex characters. Hand it to the agent and remember it until the request completes.
  2. The agent signs a proof over that nonce, your audience and the mandate’s pint_uri, and returns it with the request.
  3. Check the proof offline against the token’s cnf.jwk, then forget the nonce so the same proof can’t be presented twice.
Two kinds of proof are accepted. Both carry the same four bindings, and you check them the same way.

The AgentProof signed request

Sumvin’s agent keys sign proofs the same way they sign mandates, so the second kind is EIP-712 typed data with exactly this domain and struct:
The domain has no chainId and no verifyingContract: a proof is checked offline by you, never on chain, and pintUri already ties it to one mandate. issuedAt may arrive as a JSON integer or a decimal string.
To check it, require primaryType to be AgentProof and the domain to be exactly the one above. Compute the EIP-712 signing hash over that domain and struct, with the message’s four fields. Verify the signature over it as an agent signature against the token’s cnf.jwk, then check the four bindings.
A mandate’s own signed request is never a proof. The x-sumvin-mandate header of a mandate an agent narrowed is signed with the same key the token is bound to, and it is sent to every party the mandate is presented to. Anyone who has seen it could present it again. Refuse any signed request whose primaryType isn’t AgentProof, including a PurchaseIntent.

In Python

verify_delegation_chain runs every check on this page up to the agent’s proof, and returns the verified chain. verify_agent_proof checks the proof: nonce is required, and max_age (default 300 seconds) and leeway (default 30 seconds) set the freshness window. rpc takes a JSON-RPC URL, or a function eth_call(chain_id, to, data) that returns the call’s hex result. jwks takes the key set URL, or a jwt.PyJWKClient. Nothing returns False: every failure raises a subclass of DelegationVerificationError. Refuse on any of them. The subclass tells you which check failed:

Next

Checks and errors

The offline check, the website token check and their errors.

Limits and narrowing

What a narrowed mandate may and may not permit.