Skip to content

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_confirmed fires whether screening passed or failed. It means "we have a verdict", not "this money is good". Read data.payment.amlStatus, or watch data.paidAmount.
  • invoice.unexpected_payment fires instead of invoice.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 as invoice.payment_confirmed alone — so also check data.payment.kind === "UNEXPECTED" there.
  • There is no event for a status moving backwards. A reorg that drops an invoice from PARTIALLY_PAID to WAITING sends invoice.payment_failed; there is no invoice.unpaid. The new status is in the payload.
  • invoice.partially_paid can 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)
}
import hmac
from hashlib import sha256

def is_valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func IsValidSignature(rawBody []byte, header, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(header))
}

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.paid may land before invoice.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.