Skip to content

requirePayment & createPaymentGate

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.

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.

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 example
app.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:

kindMeaningWhat to return
'paid'A valid, recent, unused proof200 + the resource (+ result.receiptHeader)
'challenge'No proof yet (first request)402 + result.challenge
'invalid'A proof that failed verification402 + result.challenge

createPaymentGate returns a PaymentGate with six methods — all driven by you, none of which move anything on-chain except an actual verified payment:

MethodReturnsUse
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)stringRender 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).

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.

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.

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.

OptionPurpose
chain / token / amount / payToThe single-rail shorthand.
accept[]Offer multiple chains/tokens in one challenge.
rpcUrlYour RPC for verification (fold any API key in here).
minConfirmationsHow many confirmations before a proof counts. Default 1.
maxTimeoutSecondsHow long a challenge stays valid, in seconds. Default 600.
onPaidCallback after a payment verifies (sync or async; receives a PaidReceipt).
onPaidErrorObserve a failing onPaid instead of swallowing it silently.
awaitOnPaidAwait onPaid before serving the resource (default false = fire-and-forget).
onFailedMirror of onPaid: callback after a submitted proof is rejected ('invalid'), receiving a FailedPayment.
onFailedErrorObserve a failing onFailed instead of swallowing it silently (mirror of onPaidError).
awaitOnFailedAwait onFailed before the 402 is returned (default false = fire-and-forget).
generateNonceCustom per-challenge nonce generator. Default crypto.randomUUID().
isUsed / markUsedPluggable replay store for multi-instance deploys.
exactAlso 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).
uptoAlso 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.
receiptsEmit a verifiable receipt on every settled payment — a self-contained, anyone-re-verifiable record. Default off.
selfDescribeSelf-describe every 402 with an extensions.piprail block. Default true; set false to omit.
discoveryEmit the discovery manifest so crawlers can find this endpoint.

Full reference: the API page. Standard-exact selling is covered on the exact rail page.