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, for 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 or 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, so 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[] and 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, so 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, so 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, so 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, and 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, so 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(), because 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.