The request lifecycle
One fetch call can involve two HTTP requests, a policy evaluation, several balance reads, a
budget reservation, and exactly one signature. This page is the whole sequence, in order.
The order is the design. Almost every safety property tx402 offers is “X happens before Y”, so reading this page is the fastest way to understand what the SDK actually guarantees.
The happy path
Section titled “The happy path”your call │ ├─ 1. capture the body replayable, or you supply a bodyFactory ├─ 2. send the request unmodified — tx402 adds nothing yet │ │ ← 402 Payment Required, with a PAYMENT-REQUIRED header │ ├─ 3. decode strictly size, depth, duplicate keys, ≤32 requirements ├─ 4. normalize merchant's shape → tx402's, amounts to integers ├─ 5. POLICY domain → network → scheme/asset → per-request → per-hour ├─ 6. plan routes balances read concurrently, candidates ranked ├─ 7. RESERVE atomic, from your local budget ├─ 8. sign exactly one authorization, fresh nonce ├─ 9. EXPOSE fence durable, before the wire; the hold stops expiring ├─ 10. retry once carrying PAYMENT-SIGNATURE │ │ ← 200, with a PAYMENT-RESPONSE header │ ├─ 11. read settlement └─ 12. COMMIT the reservation becomes spendStep 9, the EXPOSE fence, records the reservation durably the instant before the signed request
is transmitted. It moves the reservation to exposed, which removes its expiry: an exposed hold
never expires on its own. If the fence write fails, the request is not sent (nothing can have moved)
and a retryable TransportError is raised. Only a reservation that was never transmitted still
expires at its 120-second TTL — which is exactly the case where expiry is safe.
If the resource does not answer 402, steps 3 onward never happen. A non-paying request costs
you nothing but the request itself.
The three orderings that matter
Section titled “The three orderings that matter”Policy and reservation both precede signing
Section titled “Policy and reservation both precede signing”Steps 5 and 7 complete before step 8, always, on every attempt — not just the first. This is the ordering the whole design rests on, and it is what makes the guarantees below true rather than usually true:
- A request your policy refuses costs zero signatures. Not “a signature that is discarded” — the signer is never called, so a hardware wallet never prompts and a KMS never logs a use.
- A budget cap cannot be exceeded by a race. The reservation is atomic and the store owns the comparison, so two concurrent requests cannot both see room for the last dollar.
--dry-runstops between 6 and 7. It is not a separate code path pretending to be the real one; it is the real one, halted.
Every attempt re-plans from scratch
Section titled “Every attempt re-plans from scratch”If the merchant answers the paid retry with another 402, tx402 does not reuse anything.
It re-runs policy, re-plans routes, takes a new reservation, and produces a new
signature with a fresh nonce. Nothing carries over — not the challenge, not the route, not the
authorization.
That costs a little work and buys the property that matters: a re-priced offer is honoured as a
new offer, and no authorization is ever transmitted twice. maxPaidAttempts (default 2)
bounds the loop, and exhausting it is a typed terminal error rather than a bare 402.
Release before transmission, retain after
Section titled “Release before transmission, retain after”This is the asymmetry that protects your money.
Before the signature reaches the network, any failure releases the reservation. Nothing was sent, so nothing can have settled, and holding budget would be wrong.
After the signature is on the wire, a failure means tx402 cannot know whether the
merchant settled. A timeout, a connection reset, a 5xx, and a redirect it declined to follow all
say the same thing: the outcome is unknown. Because the EXPOSE fence already ran, the reservation is
exposed — it does not expire, and it keeps consuming the cap — until an operator reconciles
it. You get AmbiguousPaymentError.
Holding is the conservative choice. Releasing would hand budget back for money that may have moved, and the same dollar could then be spent again inside the same hour. The exposed hold is never auto-retried and never dropped by a TTL; it waits for an operator to confirm whether it settled.
The AmbiguousPaymentError you receive always carries reservationExpiresAtEpochMs in its details,
and the name misleads: it is not a deadline for the exposed hold. It is the reservation’s
original pre-fence TTL — the 120-second expiry it carried before step 9’s EXPOSE fence removed
that expiry — retained only as informational context (which is why it reads as a timestamp already
in the past or near future). An exposed hold does not expire when that instant passes, or ever;
do not wait for it to lapse. It is released only when an operator
reconciles it.
Failure modes, by where they happen
Section titled “Failure modes, by where they happen”| Where | Example | Reservation | You get |
|---|---|---|---|
| Before policy | Reserved header, unreplayable body | none taken | TX402_RESERVED_HEADER, TX402_NON_REPLAYABLE |
| Policy | Over your cap, disallowed domain | none taken | TX402_POLICY_BUDGET, TX402_POLICY_DOMAIN |
| Planning | No viable route | none taken | TX402_LIQUIDITY with per-network deficits |
| Planning | No signer for any offered chain | none taken | TX402_SCHEME_UNSUPPORTED |
| Signing | Signer refused or returned garbage | released | TX402_SIGNER |
| After transmission | Timeout, reset, 5xx, same-origin 3xx | exposed | TX402_PAYMENT_AMBIGUOUS |
| After transmission | Cross-origin redirect | exposed | TX402_REDIRECT_BLOCKED, paid: "unknown" |
| After transmission | PAYMENT-RESPONSE present, undecodable |
exposed | TX402_PAYMENT_AMBIGUOUS, settlement-metadata-unparseable |
| Refused, no settlement | 4xx with no PAYMENT-RESPONSE |
released | TX402_RESOURCE_DELIVERY, paid: false |
| Delivered, not settled | success: false in PAYMENT-RESPONSE |
released | TX402_RESOURCE_DELIVERY, paid: false |
| Settled, resource refused | 403 with a successful settlement | committed | TX402_RESOURCE_DELIVERY, paid: true |
| Settled, ledger write failed | Your spend store rejected the commit | exposed | TX402_RESOURCE_DELIVERY, paid: true |
The last three rows are the ones worth reading twice.
Settlement evidence outranks the status line: if the merchant’s own PAYMENT-RESPONSE
reports a successful settlement, the money moved, so the spend is committed and you are
told paid: true whatever the status line said. The same 403 with no settlement claim is a
refusal and releases. And a settled payment whose ledger write fails is still a settled
payment, so it is reported paid: true and is never retryable — retrying is the one action
that can pay twice.
TX402_LIQUIDITY and TX402_SCHEME_UNSUPPORTED are deliberately different errors. “Everything
was attempted and fell short” and “nothing was even attempted” send an operator to two
different places, and reporting the second as the first sends them to fund a wallet that was
never the problem.
Replayable bodies
Section titled “Replayable bodies”tx402 may need to send your body twice: once on the unpaid request, once on the paid retry. For
a string, a Uint8Array, or a plain object, it captures the bytes and replays them.
A stream cannot be replayed. Rather than consume it and fail confusingly on the retry, tx402
refuses upfront with TX402_NON_REPLAYABLE — unless you supply a bodyFactory that can produce
the body again:
await tx402.fetch(url, { method: "POST", bodyFactory: () => createReadStream("large.bin"),});With a factory, you own replay semantics; tx402 calls it once per transmission.
Deadlines
Section titled “Deadlines”Every deadline in tx402 — per-RPC-provider, and the paid retry — is enforced by racing the work against a timer in tx402’s own control flow. Cancellation is requested as a courtesy but never trusted.
That sounds like an implementation detail and is not. An earlier version composed
AbortSignals, and the composition could be garbage-collected before it fired: a paid retry to
a merchant that accepted the connection and never answered would hang forever instead of
raising the AmbiguousPaymentError it owes you. Silence in exactly the case where money may have
moved. The current design cannot fail that way, because nothing that could be collected is
load-bearing.
Diagnostics
Section titled “Diagnostics”tx402 never writes to the console. It emits a structured event stream you route wherever you like:
const tx402 = createTx402Client({ signers: { evm }, logger: { debug: log, info: log, warn: log, error: log },});Events cover all fourteen names the client emits. Ten fall on the path a paid call produces them
in: request.started, payment.required, policy.checked, route.planned, budget.reserved,
sign.started, sign.completed, request.retried, payment.exposed, payment.completed. Four
are conditional: request.failed on any failure; spend.frozen when a reserve is denied because
the scope — or the whole store — is frozen by the kill switch; recipient.pinned when a
trust-on-first-use pin is established inside the reserve; and recipient.rejected when an attempt
is refused because its recipient is not pinned. Both SDKs export the list as EVENT_NAMES (and
TypeScript a Tx402EventName union), so a caller can switch on it exhaustively without string
literals.
The level is part of the contract, and it is not uniform:
| Level | Events |
|---|---|
info |
request.started, payment.required, policy.checked, route.planned, budget.reserved, request.retried, recipient.pinned |
debug |
sign.started, sign.completed — a signature attempt is detail, not a milestone |
info or error |
payment.exposed — info when the reservation is exposed just before transmission; error when that write fails and the transmit is aborted |
info or warn |
payment.completed — warn on any of three merchant faults, listed below |
warn |
spend.frozen — a reserve was denied because the scope or the whole store is frozen; recipient.rejected — an attempt was refused because its recipient is not pinned |
warn or error |
request.failed — warn when the outcome is ambiguous and the money is still reserved, error otherwise |
Three of the fourteen are emitted once per request. The happy-path eight are emitted once per
attempt, so a call the merchant re-challenges emits each of those eight twice — as the
“every attempt re-plans from scratch” section above implies, and as both languages do
identically. The remaining three are per-attempt conditionals: spend.frozen is
budget.reserved’s denial counterpart and fires in its place when the scope is frozen;
recipient.rejected fires when the attempt’s recipient is refused; and recipient.pinned fires
once, alongside budget.reserved, on the attempt whose reserve establishes a first-use pin. A
frozen or recipient-refused reserve ends the request, so neither denial repeats.
| Cardinality | Events |
|---|---|
| Once per request | request.started, payment.completed, request.failed |
| Once per attempt | payment.required, policy.checked, route.planned, budget.reserved, sign.started, sign.completed, request.retried, payment.exposed, spend.frozen, recipient.pinned, recipient.rejected |
This matters for anything that counts. budget.reserved is in the second group, so a metric
built on it counts attempts at spending, not requests — which is usually what you want, but only
if you know it. request.failed is in the first: it is emitted from a single place that picks its
level from the final disposition, so a failure counter counts failed requests and never
double-counts one.
payment.completed is emitted at warn rather than info for exactly three reasons, and only
the first two mean the merchant gave you nothing to reconcile with:
reason |
What the merchant did |
|---|---|
payment-response-absent |
Answered 200 with no PAYMENT-RESPONSE at all — committed on its word alone |
payment-response-unparseable |
Sent settlement metadata that does not decode |
settlement-succeeded-resource-unusable |
Reported a successful settlement and then failed to deliver — paid is true here |
An alert that treats payment.completed at warn as “merchant supplied no settlement evidence”
will fire on the third row too, where the evidence exists and the delivery is what broke.
budget.reserved is the one to watch. It is emitted at info immediately before a signer
becomes reachable, which makes it the last event before money can move — so it is the event
worth alerting on, and it is one of the ten a successful paid call emits ("events": 10 under
--json). A listener that only handles sign.started sees nothing at info at all. Every
event is
redaction-safe by construction: identifiers, hashes, atomic amounts, and categories only.
Signatures, keys, authorization payloads, and RPC URLs with credentials never enter the stream,
and the test suite proves it by seeding real secrets into every input and searching the whole
serialised output for each one.