Skip to content

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.

Terminal window
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.

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.

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,
});

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.

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.

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.

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_url

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

GET https://api.tuppence.ai/public/agents/agt_123/jwks.json

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