HTTP 402
@tuppence/http402 answers an unpaid request with 402 Payment Required and a way to pay,
then lets the paid retry through. A person pays on a page. An agent pays on the same page or from
prepaid credit.
1. Price a route
Section titled “1. Price a route”npm install @tuppence/node @tuppence/http402import { Tuppence } from "@tuppence/node";import { http402 } from "@tuppence/http402";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// Express, Connect or plain Node http. Paid: req.tuppenceReceipt holds the claims.app.get( "/v1/report", http402({ tuppence, price: 50, currency: "gbp", account: "acct_…" }), (req, res) => res.json(buildReport()),);import { Tuppence } from "@tuppence/node";import { http402 } from "@tuppence/http402";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// Express, Connect or plain Node http. Paid: req.tuppenceReceipt holds the claims.app.get( "/v1/report", http402({ tuppence, price: 50, currency: "usd", account: "acct_…" }), (req, res) => res.json(buildReport()),);import { Tuppence } from "@tuppence/node";import { http402 } from "@tuppence/http402";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// Express, Connect or plain Node http. Paid: req.tuppenceReceipt holds the claims.app.get( "/v1/report", http402({ tuppence, price: 50, currency: "eur", account: "acct_…" }), (req, res) => res.json(buildReport()),);Fetch-style handlers (Next.js, Hono, Bun, Deno, Workers) use withPayment:
import { withPayment } from "@tuppence/http402";
export const GET = withPayment({ tuppence, price: 50, currency: "gbp" }, async () => Response.json(buildReport()),);import { withPayment } from "@tuppence/http402";
export const GET = withPayment({ tuppence, price: 50, currency: "usd" }, async () => Response.json(buildReport()),);import { withPayment } from "@tuppence/http402";
export const GET = withPayment({ tuppence, price: 50, currency: "eur" }, async () => Response.json(buildReport()),);pip install "tuppence[http402]"import osfrom tuppence import AsyncTuppencefrom tuppence.http402 import Http402Middleware, PayWall
tuppence = AsyncTuppence(os.environ["TUPPENCE_SECRET_KEY"])
# FastAPI, Starlette, any ASGI app. Paid: request.state.tuppence_receipt holds the claims.app.add_middleware( Http402Middleware, routes={"GET /v1/report": PayWall(tuppence, price=50, currency="gbp", account="acct_…")},)import osfrom tuppence import AsyncTuppencefrom tuppence.http402 import Http402Middleware, PayWall
tuppence = AsyncTuppence(os.environ["TUPPENCE_SECRET_KEY"])
# FastAPI, Starlette, any ASGI app. Paid: request.state.tuppence_receipt holds the claims.app.add_middleware( Http402Middleware, routes={"GET /v1/report": PayWall(tuppence, price=50, currency="usd", account="acct_…")},)import osfrom tuppence import AsyncTuppencefrom tuppence.http402 import Http402Middleware, PayWall
tuppence = AsyncTuppence(os.environ["TUPPENCE_SECRET_KEY"])
# FastAPI, Starlette, any ASGI app. Paid: request.state.tuppence_receipt holds the claims.app.add_middleware( Http402Middleware, routes={"GET /v1/report": PayWall(tuppence, price=50, currency="eur", account="acct_…")},)price is in the minor unit (50 = 50p50¢50c). Set account to your acct_… to refuse receipts paid to
anyone else.
A paid MCP tool
Section titled “A paid MCP tool”On an MCP server, paidTool (Node) or paid_tool (Python) prices a tool. The tool takes an
optional receipt argument. Without a valid one, it returns where to pay as a tool error the agent
can act on (Python raises PaymentRequired).
import { paidTool } from "@tuppence/http402";
server.tool( "deep_research", schema, paidTool({ tuppence, price: 200, currency: "gbp", name: "deep_research" }, run),);import { paidTool } from "@tuppence/http402";
server.tool( "deep_research", schema, paidTool({ tuppence, price: 200, currency: "usd", name: "deep_research" }, run),);import { paidTool } from "@tuppence/http402";
server.tool( "deep_research", schema, paidTool({ tuppence, price: 200, currency: "eur", name: "deep_research" }, run),);from tuppence.http402 import PayWall, paid_tool
@mcp.tool()@paid_tool(PayWall(tuppence, price=200, currency="gbp"), "deep_research")async def deep_research(topic: str, receipt: str | None = None) -> str: ...from tuppence.http402 import PayWall, paid_tool
@mcp.tool()@paid_tool(PayWall(tuppence, price=200, currency="usd"), "deep_research")async def deep_research(topic: str, receipt: str | None = None) -> str: ...from tuppence.http402 import PayWall, paid_tool
@mcp.tool()@paid_tool(PayWall(tuppence, price=200, currency="eur"), "deep_research")async def deep_research(topic: str, receipt: str | None = None) -> str: ...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": "…" }}{ "error": { "type": "payment_required", "code": "payment_required", "message": "…" }, "price": 50, "currency": "usd", "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": "…" }}{ "error": { "type": "payment_required", "code": "payment_required", "message": "…" }, "price": 50, "currency": "eur", "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": "…" }}A Link: <…>; rel="payment" header carries the same pay URL.
- Pay at
accepts[].url. GET receipt.urlwith theTuppence-Client-Secretheader set toclient_secret. Itsreceipt_tokenis there for fifteen minutes after the payment succeeds.- Retry with
Authorization: Tuppence-Receipt <receipt_token>.
A caller who retries before paying gets the same payment for five minutes
(reuseSeconds / reuse_seconds).
error.code on a 402 | Means |
|---|---|
payment_required | Not paid yet |
receipt_already_used | That receipt opened the route once already: pay again |
credit_token_limit, credit_token_expired | The credit token is past its cap or expiry |
insufficient_credits | Not enough credit; error.topup_url sells more |
Why it is safe
Section titled “Why it is safe”| Verified offline | The receipt is a JWT signed by Tuppence (EdDSA), checked against /.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. More than one process? Pass a shared store. |
| Paid to you | With account set, receipts paid to anyone else are refused. |
Agents: pay from prepaid credit
Section titled “Agents: pay from prepaid credit”Give the route a meter at the same price and your customers’ agents can pay from prepaid credit
instead of a card. accepts[] then also lists { "type": "credits", "scheme": "Tuppence-Credits" }.
app.get( "/v1/report", http402({ tuppence, price: 50, currency: "gbp", credits: { meter: "report" } }), handler,);app.get( "/v1/report", http402({ tuppence, price: 50, currency: "usd", credits: { meter: "report" } }), handler,);app.get( "/v1/report", http402({ tuppence, price: 50, currency: "eur", credits: { meter: "report" } }), handler,);PayWall(tuppence, price=50, currency="gbp", credits_meter="report")PayWall(tuppence, price=50, currency="usd", credits_meter="report")PayWall(tuppence, price=50, currency="eur", credits_meter="report")Your server mints the agent a capped, short-lived token for its customer:
curl https://api.tuppence.ai/v1/credits/cus_…/tokens \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"max_amount": 500, "expires_in": 3600}'const { token } = await tuppence.request("POST", "/v1/credits/cus_…/tokens", { body: { max_amount: 500, expires_in: 3600 },});token = tuppence.request( "POST", "/v1/credits/cus_…/tokens", body={"max_amount": 500, "expires_in": 3600})["token"]The agent sends Authorization: Tuppence-Credits <token> with an Idempotency-Key, so a
retried request counts once. Past the cap, the expiry or the balance, it gets a 402 with a code
from the table above.
- MCP server: give an agent Tuppence’s own tools.
- Agent identity: know which agent is calling.