Skip to content

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.

Your serversk_test_…Agent agt_…private keyTuppencecredentials, mandatesjwks.jsonpublic keysAny APIverifyAgentProofA personapproves mandatepublic keypublishesTuppence-Agent-Proofchecksagrees
ProofRestricted key
Sayswho made this requestwhat may be done
Lives60 s (at most five minutes), one requestuntil revoked
Leakedgood for that one request, for secondswhoever holds it

In the simplest call, Tuppence makes the pair and returns private_key (a JWK) once. It is never stored or shown again.

Terminal window
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"}'

Send your own public_key and the private half never leaves your server.

Limit
Keys per agentFive active at once, so it can rotate without a gap
RevokeDELETE /v1/agents/{id}/credentials/{kid}, effective immediately. Revoked keys stay listed so you can still explain what an agent signed with them
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.

import { agentKeys, verifyAgentProof, MemoryReceiptStore } from "@tuppence/http402";
const keys = agentKeys("agt_123"); // fetches and caches that agent's published keys
const 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:

codeMeans
unknown_keyRevoked, or never this agent’s
bad_signatureNot signed by that key
expired_proofToo old
wrong_requestA proof for another method or URL
wrong_agentSigned as another agent
body_changedThe body isn’t the one signed
proof_already_usedReplayed

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" }
ErrorWhen
invalid_agent_proof (401)A proof was sent and does not verify. It is never treated as unsigned
agent_proof_requiredThe 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.

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.

An identity says who. A mandate says what a person agreed to. Both are signed.

Terminal window
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"]}}'
Terminal window
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"]}}'
Terminal window
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"]}}'

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.

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 limitswithin_limits: true, broke: []
Breaks themwithin_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 cartRefused outright

POST /v1/agent_mandates/{id}/revoke withdraws the mandate. Nothing more can be agreed under it.

Terminal window
curl https://api.tuppence.ai/public/agents/agt_123/jwks.json

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

Docs assistant

Open as a page
Loading the assistant…