Skip to Content
GuidesConfiguring partner webhooks

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_url field on POST /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 as TXNOD_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) || reject

Use 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

  1. 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.
  2. Filter by status=dlq to see events that exhausted their retry budget (9 attempts with exponential backoff, capped at ~8 hours).
  3. 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.
  4. To resend: click Resend (or call POST /api/v1/webhooks/events/{event_id}/resend via the SDK). The original row is never mutated; a new event with a fresh event_id and original_event_id = <old-id> is enqueued.

See Webhook Event Types for the event envelope contract and reorg semantics.

Troubleshooting

Missing invoice.paid event

  1. Confirm the invoice transitioned to paid in the dashboard (Invoices → search by external_id). If status is still detected, the chain has not yet crossed the finalization threshold — wait for the next confirmation.
  2. If the invoice is paid but no event arrived at your endpoint, check Webhooks → Events. Filter by the invoice_id.
  3. If the event exists with status=retrying or status=dlq, follow the DLQ resend procedure above.
  4. If the event exists with status=skipped, inspect skip_reason. not_subscribed means your project has no active webhook URL; no_target_url means the invoice-specific callback_url and the project-default URL were both null. Fix in Settings → Webhooks.
  5. If no event exists at all, file a support issue — this is a watcher-side bug.