Skip to Content
WebhooksWebhook Event Types

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.

  1. Invoice enters paid; TxNod emits invoice.paid with event_id = A.
  2. A reorg orphans the confirming block. The invoice flips to terminal status reverted; TxNod emits invoice.reverted with a fresh event_id = B. Treat this as the credit-reversal signal: undo whatever fulfillment you performed for A.
  3. The same transaction (or a functionally equivalent replacement) re-lands on the canonical chain at sufficient depth. The invoice enters paid again; TxNod emits invoice.paid with a fresh event_id = C — deliberately not a replay of A — and data.reason = "reorg". Treat this as the restore signal: it replays through your normal invoice.paid handler because C has never been seen by your event_id dedupe.

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:

  • null on every first-time event.
  • Set to the original event’s event_id for 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’s created_at (Unix seconds) or created_at_iso if your consumer needs a stable ordering.
  • attempt starts at 1 and increments on each retry. A resend initiated via resendWebhookEvent carries a fresh event_id and attempt = 1, with original_event_id populated on the resend-response API (not on the delivered event).