Agent identity
A restricted key says what may be done. It does not say who did it, and a key that leaks is whoever holds it. An agent can instead have a key pair of its own: it keeps the private half, Tuppence publishes the public half, and it signs a short-lived proof for each request.
Anyone can then check that this request was made by that agent — your own servers, or whoever you deal with — offline, against the published keys. A proof is worth one request, for seconds. It is not a credential to keep.
Give an agent a key
Section titled “Give an agent a key”curl -X POST https://api.tuppence.ai/v1/agents/agt_123/credentials \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"label": "worker-1"}'The response carries private_key once — a JWK, never stored and never shown again, like an
API key. Better still, generate the pair yourself and send only the public half:
import { generateKeyPairSync } from "node:crypto";
const { publicKey, privateKey } = generateKeyPairSync("ed25519");await tuppence.request("POST", "/v1/agents/agt_123/credentials", { body: { public_key: publicKey.export({ format: "jwk" }), label: "worker-1" }, idempotencyKey: crypto.randomUUID(),});Then the private half never crosses the wire at all. An agent may hold five keys at once, so it can rotate without a gap; revoking is immediate, and revoked keys stay listed because what an agent signed last week is still worth explaining.
Sign a request
Section titled “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 names the method, the URL and a hash of the body, lives 60 seconds, and carries a
jti so it cannot be replayed. Sign a fresh one per call — that is the point of it.
Check one
Section titled “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,});It throws an AgentProofError with a code you can act on: unknown_key (revoked, or never
theirs), bad_signature, expired_proof, wrong_request (a proof for another method or URL),
wrong_agent, body_changed, proof_already_used.
Tuppence’s own API
Section titled “Tuppence’s own API”Send the same proof to us and the payment 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" }A proof you send must verify: if it does not, the request is refused invalid_agent_proof
(401) rather than quietly treated as unsigned. A proof may last five minutes at most — it is for
one request, not a session — and 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 that key must carry a verified proof:
agent_proof_required otherwise. A key that leaks is then worth nothing on its own — whoever
has it still 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 they agreed to — and both are signed.
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"] }, }, idempotencyKey: crypto.randomUUID(),});// Send them mandate.approval_urlThe person opens that page themselves, sees the business, the agent, the errand and every limit in plain money, and presses one button. We record that they agreed, when, and the address they agreed from — the same evidence a saved card’s mandate carries.
Then the agent comes back with what it actually 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"), }, idempotencyKey: crypto.randomUUID(),});// → { within_limits: true, broke: [] }We check the signature is really over that cart, and then the cart against the limits. A cart
that breaks them comes back within_limits: false with broke in the words a person reads —
“it costs more than the mandate allows”, “it is at a merchant the mandate does not cover”,
“the mandate was for one purchase, and it has been used” — and it is kept, because a
refused cart is exactly what you want to see later. A signature that is not the agent’s, over
that cart, is refused outright.
POST /v1/agent_mandates/{id}/revoke takes it back; nothing more can be agreed under it.
The published keys
Section titled “The published keys”GET https://api.tuppence.ai/public/agents/agt_123/jwks.jsonNo key, no account, nothing but the agent’s id — that is what publishing them means. It says nothing else about the business, and an agent with no keys (or no such agent) answers with an empty key set rather than a 404: who holds keys is not a question strangers get answered.