Skip to content

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.

AgentYour APITuppenceGET /v1/reportcreate payment402 price, accepts[], receiptpay at accepts[].urlGET receipt.urlreceipt_tokenAuthorization: Tuppence-Receipt …verify JWT offline200 the report
Terminal window
npm install @tuppence/node @tuppence/http402
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: "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()),
);

price is in the minor unit (50 = 50p50¢50c). Set account to your acct_… to refuse receipts paid to anyone else.

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),
);
{
"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.

  1. Pay at accepts[].url.
  2. GET receipt.url with the Tuppence-Client-Secret header set to client_secret. Its receipt_token is there for fifteen minutes after the payment succeeds.
  3. 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 402Means
payment_requiredNot paid yet
receipt_already_usedThat receipt opened the route once already: pay again
credit_token_limit, credit_token_expiredThe credit token is past its cap or expiry
insufficient_creditsNot enough credit; error.topup_url sells more
Verified offlineThe 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 boughtIt names its resource and amount: a receipt for another route opens nothing.
Used onceIts jti is remembered until it expires. More than one process? Pass a shared store.
Paid to youWith account set, receipts paid to anyone else are refused.

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

Your server mints the agent a capped, short-lived token for its customer:

Terminal window
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}'

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.

Docs assistant

Open as a page
Loading the assistant…