Skip to content

Receipts & onPaid

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.

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
})

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

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 to onPaidError; 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.

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 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
}
FieldWhat it is
transactionThe 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.
amountBase units. Divide by the token’s decimals to render it (e.g. 100000 USDC = 0.10).
assetThe asset id — 'native' for the chain’s coin, otherwise the chain-specific token id.
payerThe address the payment came from, read off-chain during verification.

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.

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.

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 headerdeliverReceipt’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 budget

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 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 + retries

Hand-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 result
try {
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() result
res.setHeader(HEADER_RESPONSE, buildReceiptHeader(receipt))
// → 'eyJzY2hlbWUiOiJvbmNoYWluLXByb29mIiwic3VjY2VzcyI6dHJ1ZSwi…' (base64 JSON)

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
})

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
}
FieldWhat it is
codeThe 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.
detailThe human-readable explanation, for logs — e.g. "Paid 40000, required 500000." or "Proof 0xabc… was already redeemed.".
transienttrue 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.

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.

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.

onFailed fires on a real rejection and nothing else. The three cases it deliberately does not cover:

OutcomeonFailed?Why
kind: 'invalid' — a submitted proof was rejectedyesThis is the rejection verdict — wrong amount, expired, replayed, bad signature, …
kind: 'challenge' — no proof yet (the normal first request)noA first 402 is the start of the flow, not a failure.
verify() throws — a transient RPC error re-throws, or a 5xx SettlementErrornoA 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.)