Receipts & onPaid
Introduction
Section titled “Introduction”A gate verifies a payment on-chain, then needs to do something with it — fulfil an order,
log the spend, increment a counter. The onPaid callback is where that happens, and the
PaidReceipt it hands you is the verified record of the payment: amount
(base and formatted), asset, payer, and the settled transaction id. Everything in a receipt
was re-derived from your own trusted accept during
verification, never taken from the client. The hook is
isolated, may be sync or async, and can deliver receipts durably — all covered below.
onPaid has a mirror — onFailed — that fires when a
submitted proof is rejected (gate.verify() returns kind: 'invalid'). Wire both and the gate
notifies you of every settlement and every rejection, with the buyer told the same machine
reason; that section is at the end.
The onPaid callback
Section titled “The onPaid callback”Pass onPaid to requirePayment or
createPaymentGate. It fires after verification succeeds and the proof has been recorded as
used — so by the time it runs, the payment is real and replay-safe. It receives a
PaidReceipt: the wire receipt plus the display fields the gate already
computed (decimals, symbol, amountFormatted) and a stable idempotencyKey, so you never
need a second lookup to record or render it:
requirePayment({ chain: 'bnb', token: 'FDUSD', amount: '0.05', payTo: '0xYourWallet', onPaid: (r) => console.log(`paid ${r.amountFormatted} ${r.symbol} — tx ${r.transaction}`), // r.amount is still base units ("50000…"); r.amountFormatted is "0.05"; r.idempotencyKey = the tx id})Sync or async — and always isolated
Section titled “Sync or async — and always isolated”onPaid may be synchronous or async. Either way it is fully isolated: a thrown error or
a rejected promise is caught and routed to onPaidError — it can never break the request,
hold up a response it isn’t awaiting, or escape as an unhandledRejection that crashes the
process. So the natural async handler is safe:
onPaid: async (r) => { await db.insert('payments', { tx: r.transaction, amount: r.amount }) // a rejection here is isolated},onPaidError: (err, r) => logger.error({ err, tx: r.transaction }, 'receipt persist failed'),Fire-and-forget, or awaitOnPaid
Section titled “Fire-and-forget, or awaitOnPaid”By default onPaid is fire-and-forget: the gate does not block the response on it. That
keeps latency low, but it means a process crash between settlement and your side-effect drops
that receipt. Two ways to make it durable:
awaitOnPaid: true— the gate awaits the hook before serving the resource, so “receipt recorded” is guaranteed on the happy path (at the cost of that latency). A rejection is still isolated toonPaidError; it never turns a settled payment into a 402.- Push to a durable queue inside the hook — keep the hook fast (enqueue and return) and do
the heavy work in your own worker. For a webhook, use
deliverReceipt.
The idempotency contract
Section titled “The idempotency contract”onPaid is at-least-once. With the default in-memory replay store
it fires exactly once per proof. But across multiple instances sharing a custom
isUsed/markUsed store, two nodes can settle the same proof in a race and each fire once.
Always dedupe on receipt.idempotencyKey (a unique index or upsert):
onPaid: async (r) => { await db.payments.upsert({ where: { tx: r.idempotencyKey }, create: { …r }, update: {} })}The X402Receipt
Section titled “The X402Receipt”The receipt is the verified settlement record, identical for both the onchain-proof and
exact rails:
interface X402Receipt { scheme: 'onchain-proof' | 'exact' success: true // always true — a failed verification is a 402, never a receipt network: Caip2 // CAIP-2 network id, e.g. 'eip155:8453' transaction: string // the SETTLED on-chain tx id (see below) asset: AssetId // the token paid: a 0x… address, an SPL mint, or 'native' amount: string // base units (already scaled by decimals) payer: AddressId // who paid payTo: AddressId // your receive address, re-derived from the trusted accept verifiedAt: string // ISO timestamp of verification}| Field | What it is |
|---|---|
transaction | The on-chain id of the settled payment — an EVM/Tron/Stellar/XRPL/NEAR tx hash, a Solana signature, or a Sui digest. This is the verified tx itself, not the submit-time proof ref. |
amount | Base units. Divide by the token’s decimals to render it (e.g. 100000 USDC = 0.10). |
asset | The asset id — 'native' for the chain’s coin, otherwise the chain-specific token id. |
payer | The address the payment came from, read off-chain during verification. |
The PaidReceipt
Section titled “The PaidReceipt”onPaid and onPaidError get a PaidReceipt — every X402Receipt field plus the
merchant-facing extras the gate already resolved for the challenge, so a receipt handler never
needs a second lookup:
interface PaidReceipt extends X402Receipt { decimals: number // the token's on-chain decimals (pairs with `amount`) symbol?: string // 'USDC', 'FDUSD', … when known amountFormatted: string // "0.05" — derived from the SETTLED `amount`, not the requested price idempotencyKey: string // the settled tx id; dedupe at-least-once delivery on this}The wire X402Receipt (the header, result.receipt) stays the lean settlement record; the
enrichment is only on the hook payload.
Recording a settled payment
Section titled “Recording a settled payment”The two fields you almost always want are amount and transaction — the how much and the
proof on-chain. Together with payer they’re enough to credit an account or fulfil an order:
onPaid: (receipt) => { db.insert('payments', { payer: receipt.payer, asset: receipt.asset, amount: receipt.amount, // base units tx: receipt.transaction, // the settled on-chain id network: receipt.network, at: receipt.verifiedAt, })}Because transaction is unique and onPaid only fires after the gate’s
replay store has claimed the proof, idempotencyKey
(= transaction) is your dedupe key. On one instance it fires once per proof; across instances
it’s at-least-once, so make your write idempotent on that key.
Reliable delivery
Section titled “Reliable delivery”A stateless gate can’t be a durable webhook on its own — that’s exactly why deliverReceipt
exists. It POSTs a settled PaidReceipt to your own endpoint with retries + exponential
backoff, an HMAC-SHA256 signature, and an idempotency-key header. It never throws (a failed
delivery comes back as { delivered: false, … }), so it’s safe as the body of an onPaid hook:
import { createPaymentGate, deliverReceipt } from '@piprail/sdk'
createPaymentGate({ chain: 'bnb', token: 'FDUSD', amount: '0.05', payTo: '0xYourWallet', awaitOnPaid: true, // record before serving the resource onPaid: (r) => deliverReceipt(r, { url: process.env.RECEIPTS_WEBHOOK, // YOUR endpoint — PipRail hosts nothing secret: process.env.RECEIPTS_SECRET, // signs the body: `piprail-signature: sha256=…` }), onPaidError: (err, r) => logger.error({ err, tx: r.transaction }, 'receipt delivery threw'),})On your receiver: verify the piprail-signature (HMAC-SHA256 of the raw body with your secret)
and upsert on the idempotency-key header — deliverReceipt’s retries and at-least-once
onPaid both mean the same receipt may arrive more than once.
It retries 408/429/5xx and transport errors; a permanent 4xx stops immediately. By default
retries: 5 (up to 6 POSTs) with a 10s per-attempt timeout. Tune it
with retries, timeoutMs, backoff, headers, signatureHeader (the signature header name,
default piprail-signature), idempotencyHeader (the dedupe-key header name, default
idempotency-key), and fetchImpl (inject a fetch for tests / non-global-fetch runtimes;
defaults to globalThis.fetch) — and observe each try with onAttempt:
const result = await deliverReceipt(receipt, { url, secret, retries: 8, // up to 9 POSTs timeoutMs: 5000, // per attempt onAttempt: ({ attempt, status, willRetry }) => metrics.inc('receipt.try', { attempt, status, willRetry }),})if (!result.delivered) deadLetter.push(receipt) // give up gracefully after the budgetThe DeliverResult
Section titled “The DeliverResult”deliverReceipt returns a DeliverResult and never throws, so this returned value is the
only delivery signal — inspect error/attempts on a dead-letter path:
interface DeliverResult { delivered: boolean // true iff the endpoint returned 2xx within the attempt budget attempts: number // total POSTs made (1 + retries used) status?: number // final HTTP status, when the last attempt got a response error?: string // final error, when delivery failed}If no fetch is available — neither a global nor an injected fetchImpl — it sends nothing and
returns { delivered: false, attempts: 0, error: 'no fetch implementation available' }.
The DeliverAttempt
Section titled “The DeliverAttempt”The onAttempt callback gets a DeliverAttempt per try, for logging/metrics:
interface DeliverAttempt { attempt: number // 1-based attempt number ok: boolean // did the endpoint return a 2xx? status?: number // HTTP status, when a response was received error?: string // transport/abort error message, when no response was received willRetry: boolean // will another attempt follow?}For a queue instead of a webhook, keep the hook fast and let your worker do the heavy lifting:
onPaid: (r) => queue.add('receipt', r), // enqueue-and-return; the worker reconciles + retriesHand-rolled servers — buildReceiptHeader
Section titled “Hand-rolled servers — buildReceiptHeader”If you’re driving createPaymentGate yourself rather than using the Express middleware, the
'paid' result already carries a ready-to-send header in result.receiptHeader. Set it under
both the v2 and v1 response header names so any client reads it. Accept the inbound proof from
either the v2 payment-signature header or the legacy v1 x-payment header — the gate
parses both:
import { createPaymentGate, SettlementError, HEADER_SIGNATURE, HEADER_SIGNATURE_V1, HEADER_REQUIRED, HEADER_RESPONSE, HEADER_RESPONSE_V1,} from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet' })
let resulttry { result = await gate.verify(req.headers[HEADER_SIGNATURE] ?? req.headers[HEADER_SIGNATURE_V1]) // → { kind: 'paid', receipt, receiptHeader } on a verified, unused proof — fires onPaid // { kind: 'challenge', challenge, requiredHeader, statusCode: 402 } no proof yet — fires NEITHER hook // { kind: 'invalid', error, detail, challenge, requiredHeader, statusCode: 402 } rejected proof — fires onFailed} catch (err) { // ONLY the optional `exact` rail throws: a server-side settle failure (relayer out of gas, // facilitator down) is NOT the payer's fault — reply 5xx, never a 402 (which says "re-pay"). if (err instanceof SettlementError) { res.statusCode = 502 res.end(JSON.stringify({ x402Version: 2, error: 'settlement_failed', detail: err.message })) return } throw err}
if (result.kind === 'paid') { res.setHeader(HEADER_RESPONSE, result.receiptHeader) res.setHeader(HEADER_RESPONSE_V1, result.receiptHeader) // legacy x-payment-response // ... serve the resource} else { // 'challenge' (first hit) or 'invalid' (rejected proof) — both carry `challenge` res.setHeader(HEADER_REQUIRED, result.requiredHeader) res.statusCode = 402 res.end(JSON.stringify(result.challenge)) // always send result.challenge — it carries accepts[]}For a fully bespoke flow where you already hold an X402Receipt and need to encode it yourself,
buildReceiptHeader is the raw codec — base64 JSON of the receipt, the inverse of the client’s
parseReceipt:
import { buildReceiptHeader, HEADER_RESPONSE } from '@piprail/sdk'
const receipt = result.receipt // an X402Receipt from a 'paid' gate.verify() resultres.setHeader(HEADER_RESPONSE, buildReceiptHeader(receipt))// → 'eyJzY2hlbWUiOiJvbmNoYWluLXByb29mIiwic3VjY2VzcyI6dHJ1ZSwi…' (base64 JSON)Failure notifications — onFailed
Section titled “Failure notifications — onFailed”onPaid tells you when a payment settled. onFailed is its exact mirror: it fires when a
submitted proof is rejected — when gate.verify()
returns kind: 'invalid'. Wrong amount, expired, replayed, unknown asset, wrong recipient, a bad
exact-rail signature — any verdict that becomes a 402 rejection fires onFailed. It carries the
same machine code the buyer’s client is told for that rejection, so both sides see one
consistent reason for the same failure.
Pass it to requirePayment or createPaymentGate
exactly like onPaid — it fires through both, because it fires inside gate.verify():
requirePayment({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xYourWallet', onPaid: (r) => console.log(`paid ${r.amountFormatted} ${r.symbol} — tx ${r.transaction}`), onFailed: (f) => console.warn(`rejected: ${f.code} — ${f.detail}`), // the mirror of onPaid})The FailedPayment
Section titled “The FailedPayment”onFailed and onFailedError get a FailedPayment. A rejection has no settlement, so —
unlike a PaidReceipt — there’s no tx hash, amount, or payer to report. It’s
exactly three fields:
interface FailedPayment { code: VerifyErrorCode // the canonical rejection reason — the SAME code the buyer is given detail: string // human-readable detail, e.g. "Paid 40000, required 500000." transient: boolean // true for the two transient codes — the proof may still be settling}| Field | What it is |
|---|---|
code | The closed VerifyErrorCode for the rejection — amount_too_low, payment_expired, tx_already_used, transfer_not_found, signature_invalid, … Branch on this; it’s the same code the buyer’s client reads off extensions.piprail.code. |
detail | The human-readable explanation, for logs — e.g. "Paid 40000, required 500000." or "Proof 0xabc… was already redeemed.". |
transient | true only for the two transient codes tx_not_found / insufficient_confirmations — the proof may still be settling and the buyer auto-retries. false for a definitive rejection the buyer must fix. See below. |
The transient flag — real failures vs lag
Section titled “The transient flag — real failures vs lag”A rejection isn’t always a real failure. The two transient codes —
tx_not_found (the proof tx hasn’t propagated to your RPC node yet,
or the read itself blipped) and insufficient_confirmations (mined, but not yet
minConfirmations deep) — mean the proof may still be settling. The buyer’s client
retries automatically, so a transient: true rejection is usually
followed by onPaid on a later attempt once the node catches up.
So alert on !transient, log everything:
onFailed: (f) => { metrics.inc('payment.rejected', { code: f.code }) // count every rejection if (!f.transient) { // a DEFINITIVE rejection the buyer must fix (wrong amount, expired, replayed, bad signature) alerts.warn(`payment rejected: ${f.code} — ${f.detail}`) } // f.transient === true → normal RPC lag; the buyer is already retrying. Don't page anyone.}Nothing is hidden: every rejected attempt — transient or not — fires onFailed. The flag just
tells you whether it’s worth waking someone up. See
VerifyErrorCode for the full transient-vs-definitive table.
Sync or async — and always isolated
Section titled “Sync or async — and always isolated”Identical to onPaid. onFailed may be synchronous or async, and is fully isolated: a
thrown error or a rejected promise is caught and routed to onFailedError — it can never break
the request, hold up a response it isn’t awaiting, or escape as an unhandledRejection:
onFailed: async (f) => { await db.insert('failed_attempts', { code: f.code, detail: f.detail, transient: f.transient }) // a rejection here is isolated},onFailedError: (err, f) => logger.error({ err, code: f.code }, 'failure-record persist failed'),onFailedError(error, failure) is the mirror of onPaidError:
it observes a throw inside onFailed, and is itself isolated (even a throwing error handler can’t
break a request). Without it, a failing onFailed hook is swallowed silently — set it to log or
alert the dropped record.
Fire-and-forget, or awaitOnFailed
Section titled “Fire-and-forget, or awaitOnFailed”By default onFailed is fire-and-forget: the gate doesn’t block the 402 rejection on it.
awaitOnFailed: true mirrors awaitOnPaid — the gate awaits
the hook before the 402 is returned, so “failure recorded” is guaranteed before the caller is told.
A rejection inside the hook is still isolated to onFailedError.
When it fires — and when it doesn’t
Section titled “When it fires — and when it doesn’t”onFailed fires on a real rejection and nothing else. The three cases it deliberately does
not cover:
| Outcome | onFailed? | Why |
|---|---|---|
kind: 'invalid' — a submitted proof was rejected | yes | This is the rejection verdict — wrong amount, expired, replayed, bad signature, … |
kind: 'challenge' — no proof yet (the normal first request) | no | A first 402 is the start of the flow, not a failure. |
verify() throws — a transient RPC error re-throws, or a 5xx SettlementError | no | A throw isn’t a payment verdict — it’s an infrastructure fault. The gate returns 5xx, the buyer’s signed authorization stays valid + unused, and there’s nothing to record as a buyer failure. |
That last row is the important distinction: a definitive kind: 'invalid' rejection (the buyer’s
proof is wrong) fires onFailed; a thrown SettlementError (your relayer is out of gas, or a
facilitator is down) does not — it’s your infrastructure, not the buyer’s payment. See
the exact rail for the settle-failure path.
Recording both outcomes — the payment-system pattern
Section titled “Recording both outcomes — the payment-system pattern”The two hooks together let a gate persist a complete record — every settlement and every
rejection — to your own store, with no PipRail backend or database in the path. This is the shape
the runnable examples/basics/payment-system/
demo uses (merchant + buyer, both sides, success + failure persisted to SQLite with a /ledger
dashboard):
import { createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xYourWallet', awaitOnPaid: true, // record the receipt before serving the goods // ✅ SUCCESS — fires after a verified settlement. at-least-once → dedupe on idempotencyKey. onPaid: (r) => paymentsTable.upsert({ where: { tx: r.idempotencyKey }, create: { tx: r.transaction, payer: r.payer, amount: r.amountFormatted, symbol: r.symbol, at: r.verifiedAt }, update: {}, }), // ⚠️ FAILURE — the mirror. A rejection has no tx/amount/payer; record the reason. onFailed: (f) => failuresTable.insert({ code: f.code, detail: f.detail, transient: f.transient, at: new Date(), }), // Both hooks are fully isolated — a throw routes to the *Error seam, never breaking the request. onPaidError: (err, r) => logger.error({ err, tx: r.transaction }, 'receipt persist failed'), onFailedError: (err, f) => logger.error({ err, code: f.code }, 'failure persist failed'),})Both requirePayment and createPaymentGate fire onFailed identically — it lives inside
gate.verify(), which the middleware calls for you. (Want the buyer’s side of these same
failures — a single onEvent stream that also catches the pre-send declines the gate can’t?
See Events & observability.)