Webhook Event Types
TxNod emits seven event types. Every event carries the same envelope — the event_type literal discriminates which invoice state transition occurred. The data payload follows the strict WebhookEventData shape (universal top-level fields + a chain-namespaced chain_specific object); new fields are added as additive optional properties so existing consumers stay binary-compatible across SDK versions.
Every event is signed with HMAC-SHA256 over ${timestamp}.${rawBody} and delivered with the single combined X-Txnod-Signature: t=<unix>,v1=<hex> header. See the verifyWebhookSignature reference for the canonical handler.
invoice.detected
Emitted when the watcher observes a matching on-chain transaction at any confirmation count below the finalization threshold. Fires at most once per invoice per confirmation delta; duplicate observations from reorganizations are deduplicated by event_id.
import type { WebhookEventData } from '@txnod/sdk';
type InvoiceDetectedEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.detected';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto, amount_usd, tx_hash, amount_units, confirmations, block_height.
invoice.paid
Emitted when the invoice crosses the finalization threshold (coin-specific — e.g. 2 confirmations on BTC, 19 on TRON; see the supported chains matrix for the full per-chain table) and transitions detected → paid. Consumers should treat this as the authoritative “fulfill the order” signal.
import type { WebhookEventData } from '@txnod/sdk';
type InvoicePaidEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.paid';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto, amount_usd, tx_hash, amount_units, confirmations, block_height.
invoice.overpaid
Emitted when the total received amount exceeds the invoice amount by more than the project-configured tolerance. The invoice status is overpaid (terminal); the excess remains in the derived address and can be handled manually via the dashboard or the Orphan Payments API.
import type { WebhookEventData } from '@txnod/sdk';
type InvoiceOverpaidEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.overpaid';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto (the invoice amount), amount_usd, amount_units (the actual on-chain amount received, larger than the invoice amount on an overpayment), tx_hash, confirmations.
invoice.partial
Emitted when the received amount is below the invoice amount after the expiry window plus grace period. Non-terminal — the invoice can still transition to paid or overpaid if a follow-up payment lands before the configured partial-timeout.
import type { WebhookEventData } from '@txnod/sdk';
type InvoicePartialEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.partial';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto (the invoice amount), amount_usd, amount_units (the actual on-chain amount received, smaller than the invoice amount on a partial), tx_hash, confirmations.
invoice.expired
Emitted when the invoice expiry window elapses without a confirmed matching receipt. This covers two cases: no matching transaction was ever observed, and a detected invoice whose detection evaporated — every transaction backing it was reorged away or never reached 1 confirmation. A 0-conf mempool transaction at expiry is not enough to avoid plain expired. Terminal; the pool address is returned to cooldown. If a matching transaction confirms after expiry, the invoice can still transition to expired_paid_late (below).
import type { WebhookEventData } from '@txnod/sdk';
type InvoiceExpiredEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.expired';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto, amount_usd. tx_hash is the empty string and confirmations is 0 when no matching transaction was ever observed.
invoice.expired_paid_late
Emitted when real money arrived, late — a non-orphaned matching transaction with at least 1 confirmation. Two paths mint it: if such a transaction already exists when the expiry window elapses, the invoice expires directly as expired_paid_late (no invoice.expired is emitted); if the invoice has already expired as plain expired and a matching transaction confirms afterwards, it transitions expired → expired_paid_late at that point (the original invoice.expired event is never retracted). A 0-conf mempool transaction does not qualify on either path. Consumers should treat this as a delayed fulfillment signal and decide whether to honor the order.
import type { WebhookEventData } from '@txnod/sdk';
type InvoiceExpiredPaidLateEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.expired_paid_late';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto, amount_usd, amount_units, tx_hash, confirmations, block_height.
invoice.reverted
Emitted when a previously-paid/overpaid invoice loses its on-chain backing due to a reorganization. The invoice status flips to reverted (a terminal flag in its own right) and — if the same transaction re-lands on the canonical chain at sufficient depth — a new invoice.paid event fires with a fresh event_id, so consumers that dedupe on event_id still receive the restore signal after having reversed credit on invoice.reverted. See Reorg semantics below for the full recipe.
import type { WebhookEventData } from '@txnod/sdk';
type InvoiceRevertedEvent = {
event_id: string; // ULID — stable across retries, per-event unique
event_type: 'invoice.reverted';
created_at: number; // Unix seconds
created_at_iso: string; // ISO 8601
project_id: string; // ULID
data: WebhookEventData;
attempt: number; // 1-based delivery attempt
// 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events
mode: 'production' | 'testnet' | 'sandbox';
// Redelivery lineage: null on first-time events; set to the ORIGINAL event's
// event_id on a redelivery — both operator resends AND reorg re-confirmations.
// It is NOT a restore flag; the reorg-restore signal is `data.reason === "reorg"`.
// See Reorg semantics.
resent_from_event_id: string | null; // ULID | null
};Key data fields: invoice_id, external_id, metadata, amount_crypto, amount_usd, tx_hash, amount_units, confirmations, reason (reorg or late_arrival).
Reorg semantics
TxNod’s finalization threshold is coin-specific — BTC waits 2 confirmations, TRON 19, and so on (see supported chains for the full table) — but reorganizations can still roll back a “paid” invoice on any chain. The invoice.reverted → re-emitted invoice.paid pair is the contract that lets consumers recover without losing idempotency.
- Invoice enters
paid; TxNod emitsinvoice.paidwithevent_id = A. - A reorg orphans the confirming block. The invoice flips to terminal status
reverted; TxNod emitsinvoice.revertedwith a freshevent_id = B. Treat this as the credit-reversal signal: undo whatever fulfillment you performed forA. - The same transaction (or a functionally equivalent replacement) re-lands on the canonical chain at sufficient depth. The invoice enters
paidagain; TxNod emitsinvoice.paidwith a freshevent_id = C— deliberately not a replay ofA— anddata.reason = "reorg". Treat this as the restore signal: it replays through your normalinvoice.paidhandler becauseChas never been seen by yourevent_iddedupe.
Point 3 is the load-bearing delivery invariant. If the re-emitted invoice.paid reused event_id = A, any consumer with an event_id dedupe would silently drop it — and, having reversed the fulfillment on invoice.reverted, would never see the restore. Minting a fresh event_id guarantees the restore signal is actually delivered.
Idempotency key — event_id, never tx_hash
Dedupe deliveries on the envelope’s event_id (unique per delivery). Do not key idempotency on data.tx_hash: the restoring invoice.paid in step 3 repeats the same tx_hash as the original credit, so a tx_hash-keyed dedupe would drop the restore and leave the user un-credited after you reversed them on invoice.reverted.
The reorg-restore signal — data.reason === "reorg"
The restore is identified by data.reason === "reorg" on an invoice.paid event (it repeats a previously-reverted tx_hash). On that event you must re-apply the credit you reversed on the prior invoice.reverted — never drop it as a tx_hash duplicate. A first-time / normal invoice.paid has data.reason absent (undefined).
Redelivery lineage — resent_from_event_id
Every delivered envelope also carries resent_from_event_id — honest redelivery lineage, not a restore flag:
nullon every first-time event.- Set to the original event’s
event_idfor BOTH (a) operator-triggered resends (re-sent from the dashboard / API) and (b) reorg re-confirmations. It tells you which earlier event this delivery repeats.
Because it is set in both cases, a non-null resent_from_event_id does not by itself mean “reorg restore” — use data.reason === "reorg" for that. An operator resend (resent_from_event_id set, data.reason not "reorg") is a duplicate delivery of an already-processed event: dedupe it via resent_from_event_id (or your event_id ledger) and take no new action.
Do not treat invoice.paid as a once-per-invoice event. An invoice that goes through the revert/re-confirm cycle legitimately emits invoice.paid more than once, each with its own event_id. The correct consumer recipe: dedupe individual deliveries on event_id, treat invoice.reverted as “reverse the credit”, and treat an invoice.paid with data.reason === "reorg" for the same invoice_id as “restore the credit”. Correlate the revert/restore pair by invoice_id.
Delivery guarantees
- At-least-once: events are retried up to 9 times with exponential backoff before landing in the DLQ (see Configuring partner webhooks for resend procedures).
- In-order-per-invoice is not guaranteed across different
event_types. Use the envelope’screated_at(Unix seconds) orcreated_at_isoif your consumer needs a stable ordering. attemptstarts at 1 and increments on each retry. A resend initiated viaresendWebhookEventcarries a freshevent_idandattempt = 1, withoriginal_event_idpopulated on the resend-response API (not on the delivered event).