Add HTTP 402 to your API in 15 minutes
What you will build
- A
GET /pricedendpoint that returns 402 if no receipt is present - Receipt verification middleware that checks signatures and timestamps
- A helper to generate 402 challenges with references
Step 1: the 402 challenge response
HTTP 402 is documented by MDN as nonstandard and reserved for future use, so there is no single agreed body shape. Layered specifications such as x402 define their own headers and body. For this tutorial, the server returns a JSON body with the amount, currency, accepted rails, and a short-lived challenge reference.
HTTP/1.1 402 Payment Required
Content-Type: application/json
Cache-Control: no-store
{
"challenge": "ch_8f2c4e7a91",
"expires_at": "2025-11-03T18:45:00Z",
"amount": "0.05",
"currency": "USD",
"accepted": ["x402", "stripe"],
"resource": "/priced",
"verify_with": "https://www.originary.xyz/.well-known/peac-issuer.json"
}Step 2: the Express challenge helper
A small helper issues challenges with a short TTL and stores them in-memory. In production, store challenges in Redis or a database (see the checklist).
import { randomBytes } from 'node:crypto'
const challenges = new Map()
const CHALLENGE_TTL_MS = 5 * 60_000
export function issueChallenge(resource) {
const id = 'ch_' + randomBytes(8).toString('hex')
const expires_at = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString()
challenges.set(id, { resource, expires_at })
return { id, expires_at }
}
export function consumeChallenge(id, resource) {
const c = challenges.get(id)
if (!c) return { ok: false, reason: 'unknown_challenge' }
if (c.resource !== resource) return { ok: false, reason: 'resource_mismatch' }
if (Date.parse(c.expires_at) < Date.now()) {
challenges.delete(id)
return { ok: false, reason: 'expired' }
}
challenges.delete(id)
return { ok: true }
}Step 3: the receipt verification middleware
When a request arrives carrying a PEAC-Receipt header, verify the compact JWS, confirm it references a known challenge, and check it has not expired. Treat the payment-provider verification as a separate trust step.
import { verifyReceipt } from './your-receipt-verifier.js'
export async function requireReceipt(req, res, next) {
const header = req.get('PEAC-Receipt')
if (!header) return challenge402(req, res)
let claims
try {
claims = await verifyReceipt(header)
} catch {
return res.status(402).json({ error: 'invalid_receipt' })
}
const result = consumeChallenge(claims.challenge, req.path)
if (!result.ok) {
return res.status(402).json({ error: result.reason })
}
req.receipt = claims
next()
}
function challenge402(req, res) {
const { id, expires_at } = issueChallenge(req.path)
res.set('Cache-Control', 'no-store')
return res.status(402).json({
challenge: id,
expires_at,
amount: '0.05',
currency: 'USD',
accepted: ['x402', 'stripe'],
resource: req.path,
verify_with: 'https://www.originary.xyz/.well-known/peac-issuer.json',
})
}Step 4: wire it up
import express from 'express'
import { requireReceipt } from './require-receipt.js'
const app = express()
app.get('/priced', requireReceipt, (req, res) => {
res.json({
data: 'paid content',
receipt_ref: req.receipt?.ref,
})
})
app.listen(3000)Step 5: try it from the client side
A client first probes the endpoint, learns the price, pays through its rail of choice (x402 or a Stripe-style flow), and retries with the resulting receipt. The minimal sequence:
# 1. Probe (no receipt)
$ curl -i https://api.example.com/priced
HTTP/1.1 402 Payment Required
{"challenge":"ch_8f2c4e7a91", ...}
# 2. Pay via the chosen rail, get a signed receipt back
# 3. Retry with the receipt
$ curl -i https://api.example.com/priced \
-H "PEAC-Receipt: eyJhbGciOi...<compact JWS>..."
HTTP/1.1 200 OK
{"data":"paid content", "receipt_ref":"sha256:..."}Failure modes to handle
- missing receipt → 402 with a fresh challenge
- malformed receipt → 402 with
error: "invalid_receipt"; do not leak parser internals - expired challenge → 402 with a new challenge id
- resource mismatch → 402; the receipt was issued for a different endpoint
- replay → consume the challenge id on first use; a second request with the same id returns 402
- clock skew → allow a small tolerance window (for example 30 seconds) on receipt timestamps
What PEAC does not do
- PEAC does not custody funds, settle payments, or replace your payment rail.
- PEAC does not pick x402 vs Stripe; it carries a signed record of the exchange.
- PEAC does not assert chargeback or refund finality; that state belongs to the rail.
- PEAC does not become a billing system; compose it with your existing billing stack.
Production checklist
Before shipping to production:
- Replace the in-memory
Mapwith Redis or a database - Use your payment provider's receipt verification SDK
- Add
Cache-Control: no-storeto 402 responses - Log challenges and verifications for audit trails
- Set appropriate expiry times (5 minutes is typical)
- Handle edge cases (malformed receipts, missing keys, clock skew)
- Add rate limiting to prevent abuse
- Pin your verifier to a specific issuer config and JWKS