Configuring partner webhooks
A partner webhook is the URL on your side that TxNod calls when an invoice changes state. This page covers the two decisions you make when wiring one up: the URL format, and how to verify the HMAC signature on every inbound request.
This guide is about configuring a webhook endpoint. For the full catalogue of event-type envelopes (invoice.detected, invoice.paid, invoice.reverted, etc.), see Webhook Event Types.
URL format
A partner webhook URL has three constraints:
- HTTPS only. HTTP URLs are rejected at validation time. TxNod’s outbound dispatcher will not deliver to a plaintext endpoint, ever.
- One URL per project. A project has at most one default webhook URL. You can override it per-invoice via the
callback_urlfield onPOST /invoices, but the project-default is the fallback for invoices that don’t specify one. - Configurable in Project → Webhooks → Settings. The dashboard exposes the URL field; the SDK does not configure webhooks (it only verifies inbound ones).
A reasonable URL looks like https://my-site.com/api/txnod-webhook — a single route handler that handles every event type your project subscribes to.
Signing
Every outbound webhook is signed with HMAC-SHA256. The contract is:
- The signed payload is
${timestamp}.${rawBody}— the Unix timestamp and the raw request body, joined by a literal.. - The signature is delivered in a single combined header:
X-Txnod-Signature: t=<unix>,v1=<hex>. - The HMAC key is your project’s
TXNOD_WEBHOOK_SECRET, the same value the dashboard surfaces asTXNOD_API_SECRET. - The verifier must enforce a ±300-second timestamp window to defeat replay attacks.
Verify the signature before parsing the JSON body. Next.js’s request.json() consumes the request stream, which makes HMAC verification impossible after the fact — always read the raw body first via request.text().
The TypeScript SDK ships a helper, verifyWebhookSignature, that handles the parse-header / recompute-HMAC / constant-time-compare / timestamp-window steps and throws typed errors on each failure mode. Prefer it over hand-rolling the verification logic.
If you are not using the SDK, the equivalent loop is:
# pseudocode
parse "X-Txnod-Signature: t=<unix>,v1=<hex>" -> (t, v1)
abs(now_unix - t) <= 300 || reject
expected = hmac_sha256(secret, t + "." + raw_body)
constant_time_compare(expected, v1) || rejectUse a constant-time compare (e.g. timingSafeEqual in Node’s node:crypto) — string equality leaks bytes through timing.
Read the event log and resolve DLQ entries
- Open Webhooks → Events in the dashboard. The default view lists the most recent 50 events with status,
event_type, attempt count, and last-response status. - Filter by
status=dlqto see events that exhausted their retry budget (9 attempts with exponential backoff, capped at ~8 hours). - Click an event row to open the detail pane. It shows the full payload, every attempt’s response body and timing, and the delivery target URL.
- To resend: click Resend (or call
POST /api/v1/webhooks/events/{event_id}/resendvia the SDK). The original row is never mutated; a new event with a freshevent_idandoriginal_event_id = <old-id>is enqueued.
See Webhook Event Types for the event envelope contract and reorg semantics.
Troubleshooting
Missing invoice.paid event
- Confirm the invoice transitioned to
paidin the dashboard (Invoices → search by external_id). If status is stilldetected, the chain has not yet crossed the finalization threshold — wait for the next confirmation. - If the invoice is
paidbut no event arrived at your endpoint, check Webhooks → Events. Filter by theinvoice_id. - If the event exists with
status=retryingorstatus=dlq, follow the DLQ resend procedure above. - If the event exists with
status=skipped, inspectskip_reason.not_subscribedmeans your project has no active webhook URL;no_target_urlmeans the invoice-specificcallback_urland the project-default URL were both null. Fix in Settings → Webhooks. - If no event exists at all, file a support issue — this is a watcher-side bug.