Skip to main content
The capture adapter for the x402 payment SDK. Attach it to an x402 instance and every lifecycle-hook firing becomes a normalized, redacted Trace event. Capture is strictly passive: it observes a payment, never steers one. ESM only, Node ≥ 22, no runtime dependencies beyond @usehaia/trace-core.

Install

Attach it

One call, anywhere after you construct the instance:
That is the whole integration. trace() is idempotent per instance, and with no writer it prints NDJSON to stdout — the same encoding the file sink writes, so it can be piped into a file. The same call works on the other side of a payment:
.trace/events is where haia-trace build reads from unless --dir says otherwise — naming the same directory on both sides is what makes recording and assembly meet. The path is resolved against the working directory; pass an absolute one when your cwd is not fixed. A run can also be mirrored to a Haia Control Plane project while it is written locally — see the Control Plane sink.

What it attaches to

trace() resolves the instance’s kind by duck-typing its method set — no @x402 value is imported at runtime, so API-compatible forks work too — and stamps every event with the observing role. x402HTTPClient and x402HTTPResourceServer are wrappers that expose one hook each; the rest live on the x402Client / x402ResourceServer they hold. trace() follows into that instance, so one call still covers the whole kind.
Inference is most-specific-first, and a resource server is told apart from a facilitator by onVerifiedPaymentCanceled — a fork that drops it reads as a facilitator. Pass kind to correct that.

What it records

Every event carries adapter: "trace-x402" and the observing role; the ones belonging to a payment also carry a context_id grouping it.

Client — role: "client"

Resource server — role: "server"

Facilitator — role: "facilitator"

The same six verify/settle hooks as a resource server, with the same event types. It has no request gate and no cancellation hook. Only role separates the two sides, which is why templates constrain on it. The adapter also records its own state: trace.attached / trace.attach_partial / trace.attach_failed once per traced instance, and trace.capture_failed when a firing could not be mapped. None belongs to a payment, so none carries a context_id and the assembler reports them as unassigned.

Outcomes are read, not assumed

The onAfter* hooks fire on success and rejection alike. onVerifyFailure / onSettleFailure are reserved for a thrown fault, while a clean “not valid” or “settlement rejected” arrives as a result — so the outcome is read off the context. The client’s response hooks (onPaymentResponse, and MCP’s onAfterPayment) fire for every ending a paid request can have: The 402 is on onPaymentResponse only. MCP’s onAfterPayment is handed no offer, so a tool call that came back unsettled always reads as x402.payment.failed there. Only a settlement that actually succeeded reports x402.payment.responded — that is the client-side witness a template reads as “the payment settled”.
A paid request that ends with no settlement in sight becomes x402.payment.failed, which templates list as a fault. That covers a transport error, but also a server that settles out of band or answers 200 without a settlement header: the client cannot tell those apart, and an over-reported fault is visible where an over-reported settlement is a lie.
A resource server and a facilitator both report a decline as x402.verify.failed, but not in the same field: the SDK hands the facilitator its “not valid” through the failure hook, so the reason lands in error.message, while a resource server gets it as a result and the reason lands in verify.invalid_reason.

Redaction

Payloads are built from an allowlist, never copied from the hook context — a field the x402 SDK adds later is dropped by default rather than silently recorded. Two things are never written:
  • PaymentPayload.payload — the scheme-specific signed authorization. This is the credential that moves the money.
  • extra / extensions — open-ended bags defined by schemes and third-party extensions.
What is recorded is the payment’s public facts: the resource, the payment requirements (scheme, network, asset, amount, pay_to, timeout), the verify and settle results including the transaction hash and payer, and, for a fault, the error’s name and message. Stacks are dropped.

Grouping concurrent payments

x402 hands hooks no request id, and the adapter has to run where AsyncLocalStorage does not exist, so the context_id is derived from the contexts themselves, through two keys that both identify a payment:
  • the nonce in the signed payload (falling back to its signature), which travels with the payment;
  • for the hooks that fire before a payload exists, the object identity of the PaymentRequired the SDK threads from the 402 response through payment creation.
Identity, not content, is what separates two concurrent purchases of the same resource — their offers are byte-identical. A firing that offers neither key — an unpaid protected request, say — is recorded without a context_id rather than guessed into an operation. The id itself is a crypto.randomUUID(), unique beyond the run it was minted in, so events mirrored to a Control Plane project — which holds many runs side by side — still group correctly. Because the key comes from the payment, a client and a server traced in the same process resolve one payment to the same context_id.

Options

The package also exports its types — TraceOptions, TraceAttestation, TraceKind, TraceInstanceKind, TraceRole — and resetTraceSession(), which drops the process-wide recorder and correlator for test isolation.

Passivity

x402 lifecycle hooks steer the payment through their return value ({ abort }, { skip }, { recovered }, …). Every handler this adapter registers is wrapped in try/catch and always returns undefined, and hooks are chainable, so the recorder runs alongside your own and never displaces them. The worst case of a recorder bug is “capture stopped”, never “payment blocked”.
Set HAIA_TRACE_DISABLE=1 to make trace() a no-op.

Attestation

trace() returns what it connected to, so “no payment events happened” is distinguishable from “the recorder never wired up”:
attached labels a hook by where it registered, so a hook found on a wrapped instance reads client.onPaymentResponse or server.onAfterSettle. missing lists the hooks of the kind that registered nowhere — the firings this run will not have. The same fact is recorded as an event on every run.

From a run to receipts

The events are the input to haia-trace build, which groups them by context_id and assembles one Operation Receipt per payment:
Templates are per-role, and every witness in them names, through where: { role: ... }, the role that has to have observed it. Apply the one matching the side you traced — one build per side, even when a single run captured two (build covers why). Every buyer stage has a witness in each client kind’s flow — HTTP, MCP, and the bare x402Client — so a clean payment assembles as full from any of them.