Skip to content

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 AsyncTuppence
from 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.

{
"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.

  • 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 resource and amount: a receipt for another route opens nothing.
  • Used once. Its jti is remembered until it expires (fifteen minutes). Running more than one process? Pass a shared store.
  • Paid to you. With account set, receipts paid to anyone else are refused.

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