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

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.

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.

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.

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(), because 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.