HTTP 402
@tuppence/http402 answers an unpaid request with 402 Payment Required and a way to pay,
and lets the paid retry through. A person pays on a page; an agent pays from prepaid credit.
import { Tuppence } from "@tuppence/node";import { http402 } from "@tuppence/http402";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
app.get( "/v1/report", http402({ tuppence, price: 50, currency: "gbp", account: "acct_…" }), (req, res) => res.json(buildReport()),);Python — FastAPI, Starlette, any ASGI app
Section titled “Python — FastAPI, Starlette, any ASGI app”from tuppence import AsyncTuppencefrom tuppence.http402 import Http402Middleware, PayWall
tuppence = AsyncTuppence(os.environ["TUPPENCE_SECRET_KEY"])app.add_middleware( Http402Middleware, routes={"GET /v1/report": PayWall(tuppence, price=50, currency="gbp", account="acct_…")},)pip install "tuppence[http402]". On a paid request the receipt’s claims are in
request.state.tuppence_receipt. For an MCP server written in Python, paid_tool does the same
for a tool: without a receipt argument it raises PaymentRequired, whose message says where to
pay.
What the caller sees
Section titled “What the caller sees”{ "error": { "type": "payment_required", "code": "payment_required", "message": "…" }, "price": 50, "currency": "gbp", "resource": "GET /v1/report", "payment_id": "pay_…", "accepts": [{ "type": "card_link", "url": "https://pay.tuppence.ai/pay/pay_…" }], "receipt": { "url": "https://api.tuppence.ai/public/payments/pay_…", "header": "Tuppence-Client-Secret", "client_secret": "…" }}The caller pays at the URL, fetches its receipt, and retries with
Authorization: Tuppence-Receipt <receipt_token>. A caller who retries before paying gets the
same payment back, not a new one each time.
Why it is safe
Section titled “Why it is safe”- Verified offline. The receipt is a JWT signed by Tuppence (EdDSA), checked against our
published keys (
/.well-known/jwks.json, cached). No call to us on your hot path. - Bound to what was bought. It names its
resourceand amount: a receipt for another route opens nothing. - Used once. Its
jtiis remembered until it expires (fifteen minutes). Running more than one process? Pass a sharedstore. - Paid to you. With
accountset, receipts paid to anyone else are refused.
Agents: pay from prepaid credit
Section titled “Agents: pay from prepaid credit”Give the route a meter priced the same, and your customers’ agents can pay from their prepaid credit instead of a card:
app.get("/v1/report", http402({ tuppence, price: 50, currency: "gbp", credits: { meter: "report" } }), handler);Your server mints the agent a token for its customer — capped and short-lived — with
POST /v1/credits/{customer}/tokens (max_amount, expires_in). The agent sends
Authorization: Tuppence-Credits <token>, with an Idempotency-Key so a retried request is one
use, not two. Past the cap, the expiry or the balance, it gets a 402 saying why
(credit_token_limit, credit_token_expired, or insufficient_credits with a topup_url).