Skip to content

Events & observability

Every payment moves through stages — challenge, broadcast, confirmation, settlement — and you’ll want to see them, whether for a log line, a metric, or a progress bar in a UI. Pass an onEvent hook when you build the client and PipRail calls it once per stage with a typed PipRailEvent.

The hook is fire-and-forget: it returns nothing, and a throwing handler can never abort a payment (the call is isolated, mirroring the server gate’s onPaid).

onEvent is a constructor option. Switch on event.kind and handle the stages you care about:

import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
onEvent: (e) => console.log(e.kind, 'ref' in e ? e.ref : ''),
})
// One onchain-proof payment logs, in order:
// → payment-required
// → payment-broadcast 0xabc123…
// → payment-confirmed 0xabc123…
// → payment-settled

PipRailEvent is a discriminated union keyed on kind. One payment emits a subset of these, in order — never all of them.

kindWhen it firesPayload
payment-requiredA 402 was received and a rail chosen, before any funds move.challenge, accept
payment-broadcastThe transaction was sent — funds may already have moved.ref
payment-confirmedThe proof confirmed locally against your RPC.ref, blockNumber
payment-unconfirmedBroadcast succeeded but local confirmation timed out.ref, reason
payment-settledThe server served the resource — the payment is done.receipt, settle?
payment-failedThe flow gave up after broadcasting.reason
export type PipRailEvent =
| { kind: 'payment-required'; challenge: X402Challenge; accept: X402AnyAccept }
| { kind: 'payment-broadcast'; ref: string }
| { kind: 'payment-confirmed'; ref: string; blockNumber: bigint }
| { kind: 'payment-unconfirmed'; ref: string; reason: string }
| { kind: 'payment-settled'; receipt: X402Receipt | null; settle?: SettleOutcome }
| { kind: 'payment-failed'; reason: string }

ref is the proof — a chain-specific id (an EVM tx hash, a Solana signature, a TON locator, a Stellar tx hash). See Proof binding for what a ref is per family.

A successful onchain-proof payment emits four events in order — required, broadcast, confirmed, settled. That’s the spine of a progress indicator:

onEvent: (e) => {
// `accept.amount` is base units. On `onchain-proof`, `extra.amountFormatted`/`symbol` are the
// human form; on an `exact` rail both are optional — fall back to `accept.amount`.
if (e.kind === 'payment-required') {
ui.show(`Paying ${e.accept.extra.amountFormatted ?? e.accept.amount} ${e.accept.extra.symbol ?? ''}`)
}
if (e.kind === 'payment-broadcast') ui.show(`Sent ${e.ref.slice(0, 10)}`)
if (e.kind === 'payment-confirmed') ui.show(`Confirmed in block ${e.blockNumber}`)
if (e.kind === 'payment-settled') ui.done(e.receipt?.transaction)
}

payment-settled carries receipt — a rich X402Receipt when the server returns one (its own gate, or a facilitator that echoes the full shape), or null when it doesn’t. Read receipt.transaction for the verified on-chain settle tx:

onEvent: (e) => {
if (e.kind === 'payment-settled') {
console.log('settled', e.receipt?.transaction ?? '(no receipt)')
}
}

On standard exact interop, a conformant third-party facilitator may return a lean x402 SettleResponse instead of a rich receipt — then receipt is null and the settle tx is on event.settle.transaction:

onEvent: (e) => {
if (e.kind === 'payment-settled') {
const tx = e.receipt?.transaction ?? e.settle?.transaction
console.log('settled', tx)
}
}

payment-unconfirmed means the broadcast succeeded — you hold the ref — but the client’s own confirmation read timed out (typically a throttled or lagging RPC). The proof is not discarded: the client submits it to the server (whose own on-chain verify is the authority) rather than throwing, so a real payment is never orphaned into a double-pay. reason is the confirm error’s message.

onEvent: (e) => {
if (e.kind === 'payment-unconfirmed') {
log.warn(`broadcast ${e.ref} but confirm timed out: ${e.reason}`)
}
}

payment-failed fires only after the transaction was broadcast and the flow then gave up (the server kept returning 402, or a facilitator rejected the exact authorization). The matching MaxRetriesExceededError thrown to the caller carries .ref — re-verify or re-submit, never re-pay.

onEvent: (e) => {
if (e.kind === 'payment-failed') log.error(e.reason)
}

The hook is for observability only. It can’t approve, deny, or alter a payment — use onBeforePay for the approval gate and a spend policy for hard ceilings. A handler that throws is swallowed silently so it can never abort the payment, so don’t rely on it for control flow.