> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sumvin.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify a narrowed mandate

> Check a Stamped Mandate that was narrowed from a larger one all the way back to the person's own signature, and check that the agent presenting it holds the key it is bound to.

export const waitlists = {
  earlyAccess: "https://sumvin.com/?interest=early_access&utm_source=docs&utm_medium=banner&utm_campaign=early_access#waitlist",
  payments: "https://sumvin.com/?interest=payments&utm_source=docs&utm_medium=banner&utm_campaign=payments#waitlist",
  openBanking: "https://sumvin.com/?interest=open_banking&utm_source=docs&utm_medium=banner&utm_campaign=open_banking#waitlist",
  developers: "https://sumvin.com/developers?interest=cli,agent_plugin&utm_source=docs&utm_medium=banner&utm_campaign=developers#access",
  verifiers: "https://sumvin.com/developers?interest=verifiers&utm_source=docs&utm_medium=banner&utm_campaign=verifiers#access"
};

<Note>
  **Private preview:** this is rolling out and may not be available on your account yet.{waitlists.earlyAccess ? <> <a href={waitlists.earlyAccess}>Request early access →</a></> : null}
</Note>

A person signs a mandate. An agent can then be given a smaller mandate [narrowed](/concepts/mandates/limits#narrowing-one-mandate-into-a-smaller-one) 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](/sdk/verify/checks#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](/sdk/verify/checks#offline-check) reads, plus two more. A mandate the person signed directly carries neither.

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "pint_uri": "sr:us:pint:5d0c…",
  "wallet": "0xe23c9a70bc749ebddd8c78a864fd911d04e9e992",
  "cnf": {
    "jwk": { "kty": "EC", "crv": "P-256", "x": "H3Jcgu914hwIUGb_…", "y": "sSSx2Y0wRUyM-rnA…" }
  },
  "parent": {
    "uri": "sr:us:pint:9a41…",
    "digest": "0x4660a347382decd2366ec7708c56d49222e09145e1a84a994075111f382a7da4",
    "cnf": {
      "jwk": { "kty": "EC", "crv": "P-256", "x": "O_cAFaXngELSHx9A…", "y": "q2FR2LcGqTF1O5t0…" }
    },
    "parent": {
      "uri": "sr:us:pint:77be…",
      "digest": "0x5d2b1848e799a0f7f8250f97926d5b2449691e606a39ce9637da5caa670e1354"
    }
  }
}
```

| Claim | Means |
| - | - |
| `cnf.jwk` | The P-256 public key this mandate is bound to, as an EC JSON Web Key ([RFC 7800](https://www.rfc-editor.org/rfc/rfc7800)). `x` and `y` are each 32 bytes, unpadded base64url. |
| `parent.uri` | The identifier of the mandate this one was narrowed from. |
| `parent.digest` | The EIP-712 signing hash of that mandate's signed request: the exact 32 bytes its signer signed, lowercase hex. |
| `parent.cnf` | Present when that mandate was itself narrowed, and so bound to a key of its own. |
| `parent.parent` | The next mandate up. It nests until the mandate the person signed, which has no `parent`. |

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

| Value | Where it comes from |
| - | - |
| The token | The party presenting the mandate. |
| The signed request | The `x-sumvin-mandate` header, sent beside the token. |
| Sumvin's key set | `https://jwks.sumvin.com/.well-known/jwks.json` |
| A JSON-RPC node | Any node you trust for the chain the mandate names. It is read only when the person's signature comes from a Safe. |

The `x-sumvin-mandate` value is the unpadded base64url encoding of this JSON, with keys sorted and no whitespace:

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{"payload":{"domain":{…},"message":{…},"primaryType":"PurchaseIntent","types":{…}},"signature":"0x…"}
```

`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

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
curl "https://sis.sumvin.com/v0/sis/pint/tokens/$JTI/ancestors" \
  -H "x-sumvin-mandate: $MANDATE"
```

`$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.

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "_links": {
    "self": { "href": "/v0/sis/pint/tokens/pint-3f2a…/ancestors" }
  },
  "ancestors": [
    {
      "pint_uri": "sr:us:pint:9a41…",
      "typed_data": { "types": {…}, "primaryType": "PurchaseIntent", "domain": {…}, "message": {…} },
      "signature": "0x…",
      "signed_by": "agent",
      "cnf": { "jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" } },
      "chain_id": 1329,
      "verifying_contract": "0x…"
    },
    {
      "pint_uri": "sr:us:pint:77be…",
      "typed_data": { … },
      "signature": "0x…",
      "signed_by": "user",
      "cnf": null,
      "chain_id": 1329,
      "verifying_contract": "0x…"
    }
  ]
}
```

| Field | Means |
| - | - |
| `ancestors` | Nearest first. The last entry is the mandate the person signed. |
| `pint_uri` | The mandate's identifier. |
| `typed_data` | Its signed request, exactly as it was signed. |
| `signature` | The signature over it. |
| `signed_by` | `user` when the person signed it, `agent` when an agent key did. |
| `cnf` | The key a narrowed mandate is bound to, or `null` at the top. Verify against the token's `cnf` for that level, not this one. |
| `chain_id`, `verifying_contract` | Copied from the signed request's EIP-712 domain. |

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.

<Steps>
  <Step title="Check the token">
    Run the [offline check](/sdk/verify/checks#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.
  </Step>

  <Step title="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](#agent-signatures) against the token's `cnf`, or as the [person's signature](#the-persons-signature).
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`.
  </Step>

  <Step title="Check every signature">
    An ancestor with `signed_by: agent` must verify as an [agent signature](#agent-signatures) 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](#the-persons-signature). A chain whose top was signed only by an agent has no signature from the person, so refuse it.
  </Step>
</Steps>

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](/sdk/verify/checks#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:

```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
authenticator_data = sha256("sumvin.com") ‖ 0x05 ‖ 0x00000000           (37 bytes)
client_data_json   = {"type":"webauthn.get","challenge":"<challenge>","origin":"https://sumvin.com","crossOrigin":false}
challenge          = unpadded base64url of the 32 digest bytes
signed message     = authenticator_data ‖ sha256(client_data_json)
```

`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.

| Kind | Shape |
| - | - |
| `kb+jwt` | A compact JWS, `ES256`, header `typ: kb+jwt`, with claims `aud`, `iat`, `nonce` and `pint_uri`. |
| `sumvin-webauthn-eip712` | `{"typed_data": …, "signature": "0x…"}`: an `AgentProof`, below, with an [agent signature](#agent-signatures). |

| Binding | `kb+jwt` claim | `AgentProof` field | Must equal |
| - | - | - | - |
| The mandate | `pint_uri` | `pintUri` | The token's `pint_uri`. |
| The audience | `aud` | `audience` | Your audience, the one you pass to the offline check. |
| The nonce | `nonce` | `nonce` | The nonce you issued for this request. |
| The time | `iat` | `issuedAt` | Whole seconds since the epoch, no more than five minutes before your clock. Allow 30 seconds of clock skew either way, and refuse a time further in the future than that. |

### 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:

```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
domain     = { name: "Sumvin Agent Proof", version: "1" }
EIP712Domain(string name,string version)
AgentProof(string pintUri,string audience,string nonce,uint256 issuedAt)
```

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.

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{
  "typed_data": {
    "types": {
      "EIP712Domain": [
        { "name": "name", "type": "string" },
        { "name": "version", "type": "string" }
      ],
      "AgentProof": [
        { "name": "pintUri", "type": "string" },
        { "name": "audience", "type": "string" },
        { "name": "nonce", "type": "string" },
        { "name": "issuedAt", "type": "uint256" }
      ]
    },
    "primaryType": "AgentProof",
    "domain": { "name": "Sumvin Agent Proof", "version": "1" },
    "message": {
      "pintUri": "sr:us:pint:5d0c…",
      "audience": "reservations.example.com",
      "nonce": "9f1c4e2a7b3d8a6e5c0f1b2d3e4a5c6b",
      "issuedAt": 1760000000
    }
  },
  "signature": "0x…"
}
```

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](#agent-signatures) against the token's `cnf.jwk`, then check the four bindings.

<Warning>
  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`.
</Warning>

## In Python

<CodeGroup>
  ```bash pip theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  pip install sumvin-verify
  ```

  ```bash uv theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  uv add sumvin-verify
  ```
</CodeGroup>

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from sumvin_verify import (
    DelegationVerificationError,
    verify_agent_proof,
    verify_delegation_chain,
)

try:
    chain = verify_delegation_chain(
        token,
        request.headers["x-sumvin-mandate"],
        jwks="https://jwks.sumvin.com/.well-known/jwks.json",
        rpc="https://your-json-rpc-node.example",
        audience="reservations.example.com",
    )
    # agent_proof: the kb+jwt string, or the AgentProof {"typed_data", "signature"},
    # the agent made for the nonce you issued it
    verify_agent_proof(
        chain.claims,
        agent_proof,
        audience="reservations.example.com",
        nonce=issued_nonce,
    )
except DelegationVerificationError:
    ...  # refuse the request

chain.root_wallet  # the wallet of the person who signed the top mandate
chain.root_anchor  # "eoa" for their own key, "safe" for a key that owns their Safe
```

`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:

| Error | Means |
| - | - |
| `TokenInvalidError` | The token failed the offline check. |
| `NotADelegatedTokenError` | The token is genuine but carries no `cnf` or no `parent`: it is not a narrowed mandate. |
| `MandateHeaderError` | `x-sumvin-mandate` isn't base64url JSON with `payload` and `signature`. |
| `AncestorsUnavailableError` | Sumvin didn't return the larger mandates for this token and signed request. |
| `ChainMismatchError` | The larger mandates don't match the token: a different number, a different identifier, or a mandate that doesn't name the one above it. |
| `DigestMismatchError` | A larger mandate's signed request doesn't hash to the digest in the token. |
| `RootNotUserSignedError` | The mandate at the top wasn't signed by the person. |
| `SignatureInvalidError` | A person's signature doesn't recover to the wallet or to an owner of the Safe it names. |
| `NotSafeOwnerError` | The signing key isn't an owner of the Safe. |
| `AgentSignatureError` | An agent signature doesn't verify against the key that level is bound to. |
| `RpcError` | The chain couldn't be read, or the node serves a different chain. |
| `ProofInvalidError` | The agent proof was refused. Each subclass below names the reason. |
| `ProofSignatureError` | The proof doesn't verify against the key the token is bound to. |
| `NotAnAgentProofError` | The value isn't a `kb+jwt` or an `AgentProof`, for example a mandate's own signed request. |
| `ProofAudienceMismatchError` | The proof was made for a different audience. |
| `ProofNonceMismatchError` | The proof answers a different nonce from the one you issued. |
| `ProofMandateMismatchError` | The proof names a different mandate from the token. |
| `ProofNotFreshError` | The proof was made more than five minutes ago, or claims a time in the future. |

## Next

<Columns cols={2}>
  <Card title="Checks and errors" icon="list-checks" href="/sdk/verify/checks">
    The offline check, the website token check and their errors.
  </Card>

  <Card title="Limits and narrowing" icon="sliders-horizontal" href="/concepts/mandates/limits">
    What a narrowed mandate may and may not permit.
  </Card>
</Columns>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.