requirePayment & createPaymentGate
Introduction
Section titled “Introduction”These are the two server-side entry points. requirePayment is drop-in Express/Connect
middleware; createPaymentGate is the same logic, framework-free, for everything else. Both
turn a resource paid-only: it answers 402 until a payment verifies on-chain, then it runs.
Express / Connect — requirePayment
Section titled “Express / Connect — requirePayment”Drop it in front of any route handler:
import { requirePayment } from '@piprail/sdk'
app.get( '/report', requirePayment({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet' }), (req, res) => res.json({ report: 'unlocked' }),)The middleware issues the 402 challenge, verifies the proof on the retry, and only then calls
next(). Your handler never runs unpaid. A server-side settlement failure on the optional
exact rail (relayer out of gas / facilitator down) returns 502 — never a 402 — so a payer is
never told to re-pay for the merchant’s fault.
Any framework — createPaymentGate
Section titled “Any framework — createPaymentGate”createPaymentGate returns a PaymentGate — a plain object you drive
yourself — ideal for Hono, Fastify, Cloudflare Workers, Next.js route handlers, Bun, or Deno:
import { createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet' })
// Hono exampleapp.get('/report', async (c) => { const result = await gate.verify(c.req.header('payment-signature')) // → { kind: 'paid', receipt, receiptHeader } on a verified, unused proof
if (result.kind !== 'paid') { // 'challenge' (first hit) or 'invalid' (rejected proof) — both carry `challenge` c.header('payment-required', result.requiredHeader) return c.json(result.challenge, 402) }
c.header('payment-response', result.receiptHeader) return c.json({ report: 'unlocked' })})gate.verify() returns a discriminated
VerifyPaymentResult:
kind | Meaning | What to return |
|---|---|---|
'paid' | A valid, recent, unused proof | 200 + the resource (+ result.receiptHeader) |
'challenge' | No proof yet (first request) | 402 + result.challenge |
'invalid' | A proof that failed verification | 402 + result.challenge |
The PaymentGate object
Section titled “The PaymentGate object”createPaymentGate returns a PaymentGate with six methods — all driven by you, none of
which move anything on-chain except an actual verified payment:
| Method | Returns | Use |
|---|---|---|
gate.verify(header) | Promise<VerifyPaymentResult> | Verify the inbound payment-signature header on each request. |
gate.verifyObject(payload) | Promise<VerifyPaymentResult> | Verify an already-decoded PaymentPayload object (raw JSON, not a base64 header) — the seam for non-HTTP transports like A2A, which carry the payload as JSON metadata. Runs the identical dispatch as verify, shares the same replay set, and fires the same onPaid/onFailed; you normally let the transport call it. See A2A transport. |
gate.challenge(url?) | Promise<{ challenge, requiredHeader }> | Mint a fresh 402 challenge (new nonce) for a URL — when you issue the 402 yourself. |
gate.describe(url?) | Promise<ResourceDescription> | Static, nonce-free metadata for discovery emitters (no nonce minted). |
gate.landingPage(challenge) | string | Render the self-describing HTML landing page for a human who opens the gated URL in a browser (from a challenge). |
gate.selfTest() | Promise<GateSelfTest> | Read-only config check — never throws, never signs/sends. Resolves the rails and reports what the gate would charge ({ ok, rails, warnings }) or why it can’t ({ ok:false, error }). See Presets & self-test. |
requirePayment is just createPaymentGate wrapped in an Express adapter — it builds one gate
per gated route and reuses it (the gate’s in-memory used-proof set is what stops a proof being
redeemed twice).
Defining what you accept
Section titled “Defining what you accept”The single-rail form (chain + token + amount + payTo) is the common case. To offer
several rails at once, pass accept[] — the client pays with whatever it holds:
requirePayment({ payTo: '0xYourWallet', accept: [ { chain: 'base', token: 'USDC', amount: '0.10' }, { chain: 'polygon', token: 'USDC', amount: '0.10' }, { chain: 'solana', token: 'USDC', amount: '0.10', payTo: 'YourSolanaAddr' }, ],})Each entry can override payTo and rpcUrl for its chain (per-family payTo usually lives on
the entry, since address shapes differ across chains). The single and multi forms are mutually
exclusive — pass one or the other. See Defining
accepts for the full options.
Receipts and onPaid
Section titled “Receipts and onPaid”Pass an onPaid callback to record every settled payment — log it, fulfil an order, increment a
counter. It fires after verification succeeds, with an enriched
PaidReceipt (the wire receipt plus
decimals / symbol / amountFormatted / idempotencyKey):
requirePayment({ chain: 'bnb', token: 'FDUSD', amount: '0.05', payTo: '0xYourWallet', onPaid: (r) => console.log(`paid ${r.amountFormatted} ${r.symbol} — tx ${r.transaction}`),})onPaid may be sync or async and is fully isolated — a thrown error or a rejected promise can
never break the request (route them to onPaidError). It’s fire-and-forget by default; set
awaitOnPaid to record before the resource is served, and for a durable webhook use
deliverReceipt. Delivery is
at-least-once — dedupe on idempotencyKey. See
Receipts & onPaid for the full story.
Failure notifications — onFailed
Section titled “Failure notifications — onFailed”onFailed is the mirror of onPaid: it fires when a submitted proof is rejected — every
time gate.verify() returns kind: 'invalid' (wrong
amount, expired, replayed, unknown asset, wrong recipient, bad signature, …). Where onPaid
records a settlement, onFailed records a rejection, so you can log, count, or alert on bad
attempts with the same machinery:
requirePayment({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet', onPaid: (r) => log.info({ tx: r.transaction }, `paid ${r.amountFormatted} ${r.symbol}`), onFailed: (f) => { if (!f.transient) log.warn({ code: f.code }, `rejected: ${f.detail}`) },})It receives a FailedPayment — and because a rejection has no settlement, it’s a much leaner
shape than PaidReceipt (no tx, no amount, no payer):
interface FailedPayment { code: VerifyErrorCode // the SAME machine code the buyer's client is told (e.g. 'amount_too_low') detail: string // human text, e.g. "Paid 40000, required 500000." transient: boolean // true only for tx_not_found / insufficient_confirmations}The code is identical to the one the buyer’s client receives for that rejection (both sides see
one consistent reason — see the VerifyErrorCode table).
Use transient to avoid false alarms: it’s true only for the two transient codes
(tx_not_found / insufficient_confirmations), where the proof may still be settling and the
buyer’s client retries automatically — you’ll usually then get onPaid. Alert only on
!transient.
onFailed shares onPaid’s isolation and lifecycle exactly: it may be sync or async; a thrown
error or a rejected promise is caught and routed to onFailedError — it can never break the
request or crash the process; and it’s fire-and-forget unless you set awaitOnFailed to run
it before the 402 is returned.
Key options
Section titled “Key options”| Option | Purpose |
|---|---|
chain / token / amount / payTo | The single-rail shorthand. |
accept[] | Offer multiple chains/tokens in one challenge. |
rpcUrl | Your RPC for verification (fold any API key in here). |
minConfirmations | How many confirmations before a proof counts. Default 1. |
maxTimeoutSeconds | How long a challenge stays valid, in seconds. Default 600. |
onPaid | Callback after a payment verifies (sync or async; receives a PaidReceipt). |
onPaidError | Observe a failing onPaid instead of swallowing it silently. |
awaitOnPaid | Await onPaid before serving the resource (default false = fire-and-forget). |
onFailed | Mirror of onPaid: callback after a submitted proof is rejected ('invalid'), receiving a FailedPayment. |
onFailedError | Observe a failing onFailed instead of swallowing it silently (mirror of onPaidError). |
awaitOnFailed | Await onFailed before the 402 is returned (default false = fire-and-forget). |
generateNonce | Custom per-challenge nonce generator. Default crypto.randomUUID(). |
isUsed / markUsed | Pluggable replay store for multi-instance deploys. |
exact | Also accept the standard exact scheme — zero-config keyless (exact: true, Mode 0 — start here), your own relayer (settle: 'self', Mode A), or a named facilitator (settle: { facilitator }, Mode B). |
upto | Also advertise the metered / variable-amount upto rail (buyer signs a MAX; you settle the actual). EVM-Permit2 only; settle with a direct gate.verify() — it throws through requirePayment. |
receipts | Emit a verifiable receipt on every settled payment — a self-contained, anyone-re-verifiable record. Default off. |
selfDescribe | Self-describe every 402 with an extensions.piprail block. Default true; set false to omit. |
discovery | Emit the discovery manifest so crawlers can find this endpoint. |
Full reference: the API page. Standard-exact selling is covered on the
exact rail page.