Agent identity
A restricted key says what may be done, not who did it, and anyone holding a leaked key can use it. Give an agent its own key pair instead. The agent keeps the private half, Tuppence publishes the public half, and the agent signs a short-lived proof for each request. Anyone (your servers, the businesses you deal with, Tuppence) checks it offline against the published keys.
| Proof | Restricted key | |
|---|---|---|
| Says | who made this request | what may be done |
| Lives | 60 s (at most five minutes), one request | until revoked |
| Leaked | good for that one request, for seconds | whoever holds it |
1. Give an agent a key
Section titled “1. Give an agent a key”In the simplest call, Tuppence makes the pair and returns private_key (a JWK) once. It is
never stored or shown again.
curl https://api.tuppence.ai/v1/agents/agt_123/credentials \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"label": "worker-1"}'// Better: make the pair yourself and send only the public half.import { generateKeyPairSync } from "node:crypto";
const { publicKey, privateKey } = generateKeyPairSync("ed25519");const credential = await tuppence.request("POST", "/v1/agents/agt_123/credentials", { body: { public_key: publicKey.export({ format: "jwk" }), label: "worker-1" },});credential = tuppence.request( "POST", "/v1/agents/agt_123/credentials", body={"label": "worker-1"})private_key = credential["private_key"] # a JWK, shown onceSend your own public_key and the private half never leaves your server.
| Limit | |
|---|---|
| Keys per agent | Five active at once, so it can rotate without a gap |
| Revoke | DELETE /v1/agents/{id}/credentials/{kid}, effective immediately. Revoked keys stay listed so you can still explain what an agent signed with them |
2. Sign a request
Section titled “2. Sign a request”import { signAgentProof, AGENT_PROOF_HEADER } from "@tuppence/node";
const body = JSON.stringify({ report: "q3" });const proof = signAgentProof({ agent: "agt_123", kid: "ak_…", // from the credential privateKey, // the JWK you kept method: "POST", url: "https://api.acme.test/reports", body, // the exact bytes you will send});
await fetch("https://api.acme.test/reports", { method: "POST", headers: { "content-type": "application/json", [AGENT_PROOF_HEADER]: proof }, body,});The proof covers the method, the URL and a hash of the body. It lasts 60 seconds and carries a
jti, so it cannot be replayed. Sign a fresh one per call.
3. Check one
Section titled “3. Check one”import { agentKeys, verifyAgentProof, MemoryReceiptStore } from "@tuppence/http402";
const keys = agentKeys("agt_123"); // fetches and caches that agent's published keysconst store = new MemoryReceiptStore(); // so one proof is used once
const claims = await verifyAgentProof(request.headers.get("Tuppence-Agent-Proof"), keys, { agent: "agt_123", method: "POST", url: "https://api.acme.test/reports", body: await request.text(), store,});On failure it throws an AgentProofError with a code:
code | Means |
|---|---|
unknown_key | Revoked, or never this agent’s |
bad_signature | Not signed by that key |
expired_proof | Too old |
wrong_request | A proof for another method or URL |
wrong_agent | Signed as another agent |
body_changed | The body isn’t the one signed |
proof_already_used | Replayed |
Tuppence’s own API
Section titled “Tuppence’s own API”Send the same header to Tuppence and the object records who made it, not just which key:
const body = JSON.stringify({ amount: 2000, currency: "gbp" });const payment = await fetch("https://api.tuppence.ai/v1/payments", { method: "POST", headers: { authorization: `Bearer ${process.env.TUPPENCE_SECRET_KEY}`, "idempotency-key": crypto.randomUUID(), "content-type": "application/json", "tuppence-agent-proof": signAgentProof({ agent: "agt_123", kid, privateKey, method: "POST", url: "https://api.tuppence.ai/v1/payments", }), }, body,});// → { …, "agent": "agt_123" }const body = JSON.stringify({ amount: 2000, currency: "usd" });const payment = await fetch("https://api.tuppence.ai/v1/payments", { method: "POST", headers: { authorization: `Bearer ${process.env.TUPPENCE_SECRET_KEY}`, "idempotency-key": crypto.randomUUID(), "content-type": "application/json", "tuppence-agent-proof": signAgentProof({ agent: "agt_123", kid, privateKey, method: "POST", url: "https://api.tuppence.ai/v1/payments", }), }, body,});// → { …, "agent": "agt_123" }const body = JSON.stringify({ amount: 2000, currency: "eur" });const payment = await fetch("https://api.tuppence.ai/v1/payments", { method: "POST", headers: { authorization: `Bearer ${process.env.TUPPENCE_SECRET_KEY}`, "idempotency-key": crypto.randomUUID(), "content-type": "application/json", "tuppence-agent-proof": signAgentProof({ agent: "agt_123", kid, privateKey, method: "POST", url: "https://api.tuppence.ai/v1/payments", }), }, body,});// → { …, "agent": "agt_123" }| Error | When |
|---|---|
invalid_agent_proof (401) | A proof was sent and does not verify. It is never treated as unsigned |
agent_proof_required | The key has require_agent_proof and the request carries no verified proof |
A proof lasts five minutes at most. The URL is matched by path, so proxies and hostnames don’t matter.
A key that only works with a proof
Section titled “A key that only works with a proof”Set require_agent_proof on an API key (Developers → API keys, or
POST /dashboard/api_keys/{id}) and every request with it must carry a verified proof. A leaked
key is then useless on its own, because whoever has it cannot sign as the agent. Rolling the key
keeps the setting.
Mandates: what a person agreed it may buy
Section titled “Mandates: what a person agreed it may buy”An identity says who. A mandate says what a person agreed to. Both are signed.
1. Ask the person
Section titled “1. Ask the person”curl https://api.tuppence.ai/v1/agent_mandates \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"agent": "agt_123", "description": "Book one return flight to New York, economy, under £400.", "limits": {"max_amount": 40000, "currency": "gbp", "max_uses": 1, "merchants": ["Skyways"]}}'curl https://api.tuppence.ai/v1/agent_mandates \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"agent": "agt_123", "description": "Book one return flight to New York, economy, under $400.", "limits": {"max_amount": 40000, "currency": "usd", "max_uses": 1, "merchants": ["Skyways"]}}'curl https://api.tuppence.ai/v1/agent_mandates \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"agent": "agt_123", "description": "Book one return flight to New York, economy, under €400.", "limits": {"max_amount": 40000, "currency": "eur", "max_uses": 1, "merchants": ["Skyways"]}}'const mandate = await tuppence.request("POST", "/v1/agent_mandates", { body: { agent: "agt_123", description: "Book one return flight to New York, economy, under £400.", limits: { max_amount: 40000, currency: "gbp", max_uses: 1, merchants: ["Skyways"] }, },});// Send them mandate.approval_urlconst mandate = await tuppence.request("POST", "/v1/agent_mandates", { body: { agent: "agt_123", description: "Book one return flight to New York, economy, under $400.", limits: { max_amount: 40000, currency: "usd", max_uses: 1, merchants: ["Skyways"] }, },});// Send them mandate.approval_urlconst mandate = await tuppence.request("POST", "/v1/agent_mandates", { body: { agent: "agt_123", description: "Book one return flight to New York, economy, under €400.", limits: { max_amount: 40000, currency: "eur", max_uses: 1, merchants: ["Skyways"] }, },});// Send them mandate.approval_urlmandate = tuppence.request("POST", "/v1/agent_mandates", body={ "agent": "agt_123", "description": "Book one return flight to New York, economy, under £400.", "limits": {"max_amount": 40000, "currency": "gbp", "max_uses": 1, "merchants": ["Skyways"]},})# Send them mandate["approval_url"]mandate = tuppence.request("POST", "/v1/agent_mandates", body={ "agent": "agt_123", "description": "Book one return flight to New York, economy, under $400.", "limits": {"max_amount": 40000, "currency": "usd", "max_uses": 1, "merchants": ["Skyways"]},})# Send them mandate["approval_url"]mandate = tuppence.request("POST", "/v1/agent_mandates", body={ "agent": "agt_123", "description": "Book one return flight to New York, economy, under €400.", "limits": {"max_amount": 40000, "currency": "eur", "max_uses": 1, "merchants": ["Skyways"]},})# Send them mandate["approval_url"]The person opens approval_url, sees the business, the agent, the errand and every limit in plain
money, and presses one button. Tuppence records that they agreed, when, and the address they
agreed from: the same evidence a saved card’s mandate carries.
2. The agent signs its cart
Section titled “2. The agent signs its cart”The agent then submits what it wants to buy, signed with its own key:
import { createHash, createPrivateKey, sign } from "node:crypto";
const cart = { amount: 35000, currency: "gbp", merchant: "Skyways", items: [...] };const canonical = [ `mandate=${mandate.id}`, `agent=agt_123`, `amount=${cart.amount}`, `currency=${cart.currency}`, `merchant=${cart.merchant.toLowerCase()}`, ...cart.items.map((i, n) => `item${n}=${i.description.toLowerCase()}:${i.amount}`),].join("\n");const digest = createHash("sha256").update(canonical).digest("base64url");
await tuppence.request("POST", `/v1/agent_mandates/${mandate.id}/carts`, { body: { ...cart, kid, signature: sign(null, Buffer.from(digest), createPrivateKey({ key: privateKey, format: "jwk" })) .toString("base64url"), },});// → { within_limits: true, broke: [] }import { createHash, createPrivateKey, sign } from "node:crypto";
const cart = { amount: 35000, currency: "usd", merchant: "Skyways", items: [...] };const canonical = [ `mandate=${mandate.id}`, `agent=agt_123`, `amount=${cart.amount}`, `currency=${cart.currency}`, `merchant=${cart.merchant.toLowerCase()}`, ...cart.items.map((i, n) => `item${n}=${i.description.toLowerCase()}:${i.amount}`),].join("\n");const digest = createHash("sha256").update(canonical).digest("base64url");
await tuppence.request("POST", `/v1/agent_mandates/${mandate.id}/carts`, { body: { ...cart, kid, signature: sign(null, Buffer.from(digest), createPrivateKey({ key: privateKey, format: "jwk" })) .toString("base64url"), },});// → { within_limits: true, broke: [] }import { createHash, createPrivateKey, sign } from "node:crypto";
const cart = { amount: 35000, currency: "eur", merchant: "Skyways", items: [...] };const canonical = [ `mandate=${mandate.id}`, `agent=agt_123`, `amount=${cart.amount}`, `currency=${cart.currency}`, `merchant=${cart.merchant.toLowerCase()}`, ...cart.items.map((i, n) => `item${n}=${i.description.toLowerCase()}:${i.amount}`),].join("\n");const digest = createHash("sha256").update(canonical).digest("base64url");
await tuppence.request("POST", `/v1/agent_mandates/${mandate.id}/carts`, { body: { ...cart, kid, signature: sign(null, Buffer.from(digest), createPrivateKey({ key: privateKey, format: "jwk" })) .toString("base64url"), },});// → { within_limits: true, broke: [] }Tuppence checks that the signature covers that cart, then checks the cart against the limits.
| Result | |
|---|---|
| Within the limits | within_limits: true, broke: [] |
| Breaks them | within_limits: false, with broke in plain words, such as “it costs more than the mandate allows”, “it is at a merchant the mandate does not cover” or “the mandate was for one purchase, and it has been used”. The cart is kept so you can review refused carts later |
| Signature not the agent’s, over that cart | Refused outright |
POST /v1/agent_mandates/{id}/revoke withdraws the mandate. Nothing more can be agreed under it.
The published keys
Section titled “The published keys”curl https://api.tuppence.ai/public/agents/agt_123/jwks.jsonIt needs no key or account, only the agent’s id, and reveals nothing else about the business. An agent with no keys (or no such agent) returns an empty key set rather than a 404, so strangers cannot tell who holds keys.
- MCP server: the tools an agent can call.
- HTTP 402: charge agents per request.