Webhooks¶
A webhook subscription tells NullSwap where to POST when something happens to one of your invoices.
It is the supported way to learn that a payment arrived; polling the invoice is a reconciliation
tool, not an integration.
Subscriptions are created in the cabinet, not over the API
Adding, editing and deleting a webhook is an administrative act, and an API key is never granted
it. Open your merchant in the cabinet and add the endpoint URL and the events there. There is no
POST, PUT or DELETE on /api/v1/webhooks.
What the API gives you is what a backend needs: the secret to verify signatures with, and the delivery log to check when a hook you expected never arrived.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/webhooks |
List your subscriptions |
GET |
/api/v1/webhooks/{webhookId} |
Read one |
GET |
/api/v1/webhooks/{webhookId}/deliveries |
Delivery history |
Reading your subscription¶
curl "https://api.pay.nullswap.com/api/v1/webhooks?status=ACTIVE&limit=20" \
-H "x-api-key: sk_live_..."
{
"data": [{
"id": "c3d4e5f6-a7b8-4901-a234-5b6c7d8e9f01",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"url": "https://shop.example.com/hooks/nullswap",
"secret": "8f14e45fceea167a5a36dedd4bea2543a1b2c3d4e5f60718293a4b5c6d7e8f90",
"events": ["invoice.paid", "invoice.expired"],
"status": "ACTIVE",
"createdAt": "2026-01-01T12:00:00.000Z"
}],
"pagination": { "offset": 0, "limit": 20, "total": 1, "order": "DESC" }
}
The secret is 64 hexadecimal characters and is readable at any time — it is not a
show-once-at-creation value. Load it at start-up (or cache it briefly) rather than hard-coding it, so
that rotating it in the cabinet does not require a redeploy.
status is ACTIVE or DISABLED. Disabling a subscription stops new events being queued for it; it
does not cancel deliveries already waiting to go out, so an endpoint can still receive a short tail
after the subscription is switched off.
Events¶
| Event | Fires when |
|---|---|
invoice.created |
An invoice is created |
invoice.payment_detected |
A deposit is visible on chain, unconfirmed |
invoice.payment_confirmed |
A deposit reached the required depth and screening returned a verdict |
invoice.payment_failed |
A deposit was reverted or lost to a reorg |
invoice.partially_paid |
The invoice moved into PARTIALLY_PAID |
invoice.paid |
The invoice moved into PAID |
invoice.swapping |
Settlement started swapping the funds |
invoice.completed |
Settlement finished |
invoice.failed |
Settlement failed |
invoice.expired |
The deadline passed without sufficient payment |
invoice.unexpected_payment |
A deposit was classified UNEXPECTED when first seen |
Subscribe only to what you act on. invoice.paid is the fulfilment signal;
invoice.payment_detected is progress-bar material and must not release goods, because an
unconfirmed transaction can still disappear.
Event names are matched exactly. There are no wildcards, so invoice.* subscribes to nothing.
What the events do not tell you¶
invoice.payment_confirmedfires whether screening passed or failed. It means "we have a verdict", not "this money is good". Readdata.payment.amlStatus, or watchdata.paidAmount.invoice.unexpected_paymentfires instead ofinvoice.payment_detected, not in addition to it. It is also only emitted from the detection path. If the first thing seen for a deposit is its confirmation, an unexpected payment surfaces asinvoice.payment_confirmedalone — so also checkdata.payment.kind === "UNEXPECTED"there.- There is no event for a status moving backwards. A reorg that drops an invoice from
PARTIALLY_PAIDtoWAITINGsendsinvoice.payment_failed; there is noinvoice.unpaid. The new status is in the payload. invoice.partially_paidcan fire more than once, as further deposits arrive.- Static wallets emit no webhooks at all. Reconcile them by polling
GET /api/v1/static-wallets/payments.
Delivery format¶
Each delivery is a POST with a JSON body and four headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Nullswap-Event |
The event type, e.g. invoice.paid |
X-Nullswap-Delivery |
Delivery UUID — deduplicate on this |
X-Nullswap-Signature |
sha256=<hex> HMAC of the raw body |
The body has exactly three top-level keys:
{
"event": "invoice.paid",
"occurredAt": "2026-01-01T12:07:52.000Z",
"data": {
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"depositAddress": "TQ5s...9Xk2",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"acceptsAnyToken": false,
"paidAmount": "25500000",
"pendingAmount": "0",
"externalId": "order-10241",
"status": "PAID",
"createdAt": "2026-01-01T12:00:00.000Z",
"expiresAt": "2026-01-01T13:00:00.000Z",
"updatedAt": "2026-01-01T12:07:52.000Z"
}
}
data is a snapshot of the invoice at the time of the event. It matches the invoice you would read
from GET /api/v1/invoices/{invoiceId} with one exception: the webhook payload has no amlStatus
field.
There is no id in the body. The delivery id lives only in the X-Nullswap-Delivery header.
Payment events carry data.payment¶
On invoice.payment_detected, invoice.payment_confirmed, invoice.payment_failed and
invoice.unexpected_payment, the invoice snapshot gains a payment object describing the deposit
that caused the event:
{
"event": "invoice.payment_detected",
"occurredAt": "2026-01-01T12:04:11.000Z",
"data": {
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"status": "PAYMENT_DETECTED",
"paidAmount": "0",
"pendingAmount": "25500000",
"payment": {
"id": "d4e5f6a7-b8c9-4012-a345-6b7c8d9e0f12",
"chainTransactionId": "aa11bb22-cc33-4d44-8e55-6f7788990011",
"chainTransferId": "bb22cc33-dd44-4e55-8f66-778899001122",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"hash": "0xabcdef...7890",
"blockNumber": 21000042,
"fromAddress": "TJ8k...2Ba7",
"amount": "25500000",
"kind": "EXPECTED",
"unexpectedReason": null,
"resolution": "PENDING",
"status": "DETECTED",
"confirmations": 0,
"amlStatus": null,
"amlResultId": null,
"createdAt": "2026-01-01T12:04:11.000Z",
"updatedAt": "2026-01-01T12:04:11.000Z"
}
}
}
Two differences from the payment you read over REST: data.payment carries hash and blockNumber,
which the REST representation does not, and it omits refundAddress and refundTxHash, which it
does. blockNumber is null on invoice.payment_confirmed.
Verifying the signature¶
Compute HMAC-SHA256(secret, raw_request_body) and compare it, in constant time, against the hex in
X-Nullswap-Signature. The secret is used as a UTF-8 string, not decoded from hex.
import { createHmac, timingSafeEqual } from "node:crypto"
// express.raw({ type: "application/json" }) — req.body must be the exact bytes we sent.
export function isValidSignature(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(header ?? "")
return a.length === b.length && timingSafeEqual(a, b)
}
Sign the raw bytes
Verify before parsing, against the body exactly as received. Re-serialising the parsed JSON changes key order and whitespace, and the signature will never match. Reject any request whose signature does not verify — do not log it as a warning and process it anyway.
Note that the signature contains no timestamp, so it is not by itself replay protection. Deduplicating
on X-Nullswap-Delivery is what makes a replay harmless.
If you have several subscriptions pointed at the same URL, each one produces its own delivery with its own id, signed with its own secret. Verify against the secret of the subscription you resolved, or try each in turn.
Retries, ordering and idempotency¶
Each attempt times out after 10 seconds. Anything other than a 2xx counts as a failure and is
retried with the following backoff:
| Attempt | Waits |
|---|---|
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
| 6 and later | 1 hour, repeating |
Retries do not stop
There is no attempt limit and no dead-letter after which a delivery is given up on. A 4xx is
retried exactly like a 5xx — returning 410 Gone will not make us stop. An endpoint that has
been down for a day will receive its entire backlog once it recovers.
That backlog is why two properties of your handler are not optional:
- Deduplicate on
X-Nullswap-Delivery. The same delivery id can arrive more than once. Record the id and ignore repeats. - Do not assume ordering. Deliveries are dispatched in batches, and a delivery that failed once
falls behind newer ones —
invoice.paidmay land beforeinvoice.partially_paid. Treat every payload as a snapshot of the invoice at the time of the event, not as a diff, and ignore an event describing a state you have already moved past.
Answer 2xx as soon as you have stored the event. Do the slow work — fulfilment, emails, accounting —
after you have responded. A handler that takes longer than 10 seconds is a handler that will receive
everything twice.
A minimal handler therefore looks like this:
1. read the raw body
2. verify X-Nullswap-Signature, reject on mismatch
3. INSERT the X-Nullswap-Delivery id; on conflict, return 200 and stop
4. parse, and ignore the event if data.status is behind what you have recorded
5. return 200
6. do the real work asynchronously
Inspecting deliveries¶
curl "https://api.pay.nullswap.com/api/v1/webhooks/{webhookId}/deliveries?status=FAILED&limit=50" \
-H "x-api-key: sk_live_..."
Filters on id, invoiceId, eventType and status (PENDING, DELIVERED, FAILED), with the
usual limit / offset / sortBy / order.
{
"id": "e5f6a7b8-c9d0-4123-a456-7b8c9d0e1f23",
"webhookId": "c3d4e5f6-a7b8-4901-a234-5b6c7d8e9f01",
"invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"eventType": "invoice.paid",
"payload": "{\"event\":\"invoice.paid\",...}",
"status": "PENDING",
"attempts": 4,
"lastError": "Receiver responded with 500",
"nextRetryAt": "2026-01-01T12:20:00.000Z",
"deliveredAt": null,
"createdAt": "2026-01-01T12:07:52.000Z"
}
payload is the exact JSON string that was, or will be, sent — the same bytes the signature was
computed over.
This is where to look when a hook you were waiting on never arrived. It distinguishes three cases that otherwise look identical from your side:
| What you see | What it means |
|---|---|
| No row for that invoice and event | The event never fired |
status: FAILED or PENDING with attempts > 0 and a lastError |
We tried; your endpoint refused it |
status: DELIVERED with a deliveredAt |
We sent it and got a 2xx; it was lost on your side |
There is no redelivery endpoint on the merchant API
If you dropped a delivery you had already answered 2xx to, reconstruct the state by reading the
invoice — GET /api/v1/invoices/{invoiceId} and
GET /api/v1/invoices/{invoiceId}/payments — rather than waiting for a replay. The payload was
only ever a snapshot of those.