Skip to content

Webhooks

A webhook subscription tells NullSwap where to POST when something happens to one of your invoices or conversions. 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, signed in as its owner, and add the endpoint URL and the events under Settings → Webhooks. Other members can see the subscriptions there but not change them.

The merchant API has no webhook routes at all, not even to read a subscription or its delivery history: both live in the cabinet.

Your subscriptions

The signing secret is not returned by the API

The merchant API never returns the secret you verify signatures with, so a leaked API key cannot be used to forge deliveries. Read it in the cabinet, signed in as a member of the merchant: it is 64 hexadecimal characters and stays readable there at any time. Put it in your backend's secret storage, not in code.

A subscription is ACTIVE or DISABLED. Disabling one stops new events being queued for it; it does not cancel deliveries already waiting to go out, so your endpoint can keep receiving those for a while after the subscription is switched off.

Deleting a subscription in the cabinet deletes its pending deliveries and its delivery history with it.

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 The invoice moved into SWAPPING: a sweep of its deposit address put money in flight
invoice.completed The invoice moved into COMPLETED: no sweep of its deposit address is still running or failed
invoice.failed The invoice moved into FAILED: a sweep of its deposit address gave up, and the money is still on the address
invoice.expired The deadline passed without sufficient payment
invoice.unexpected_payment A deposit was classified UNEXPECTED when first seen
conversion.completed A conversion finished and the target token was credited to your balance
conversion.failed A conversion you started in the cabinet did not happen; what it debited went back
conversion.skipped A sweep covered by an auto-conversion rule was credited unconverted

invoice.swapping, invoice.completed and invoice.failed follow the sweeps of the invoice's deposit address, automatic ones and the ones you request. An automatic sweep moves the invoice only once it is PAID. invoice.failed is not final: a later sweep moves the invoice on to SWAPPING or COMPLETED and the matching event fires again. See the status lifecycle.

The conversion.* events describe conversions: one you start in the cabinet, and one an auto-conversion rule applies to a sweep. See conversion events.

Subscribe only to what you act on. invoice.paid is the fulfilment signal. invoice.payment_detected is only good for a progress indicator: it 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 of its own for a status moving backwards. A reorg that drops an invoice from PARTIALLY_PAID to WAITING, or out of PAID, sends invoice.payment_failed; there is no invoice.unpaid. The new status is in the payload. A drop from PAID to PARTIALLY_PAID also sends invoice.partially_paid, queued before the invoice.payment_failed. An invoice that is paid again after that sends invoice.paid a second time.
  • invoice.partially_paid can fire more than once, as further deposits arrive.
  • No event covers a static wallet payment. Reconcile them by polling GET /api/v1/static-wallets/payments. The only events a static wallet can cause are conversion.completed and conversion.skipped, when an auto-conversion rule covers its sweep.

Delivery format

Each delivery is a POST with a JSON body and five 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-Timestamp When this attempt was sent, in Unix seconds, e.g. 1767268800
X-Nullswap-Signature sha256=<hex> HMAC of the timestamp and 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",
    "depositTag": null,
    "depositXAddress": null,
    "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
    "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
    "expectedAmount": "25500000",
    "acceptsAnyToken": false,
    "autoSweep": true,
    "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. depositTag and depositXAddress are set on RIPPLE invoices only, and null everywhere else; on RIPPLE they are what your customer has to pay to — see Destination tags on RIPPLE.

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,
      "blockTimestamp": "2026-01-01T12:03:59.000Z",
      "fromAddress": "TJ8k...2Ba7",
      "amount": "25500000",
      "kind": "EXPECTED",
      "unexpectedReason": null,
      "resolution": "ACCEPTED",
      "status": "DETECTED",
      "confirmations": 0,
      "amlStatus": null,
      "amlResultId": null,
      "createdAt": "2026-01-01T12:04:11.000Z",
      "updatedAt": "2026-01-01T12:04:11.000Z"
    }
  }
}

data.payment differs from the payment you read over REST: it carries chainTransferId, hash, blockNumber and blockTimestamp, which the REST representation does not, and it omits invoiceId, refundAddress, refundTag and refundTxHash, which it has. blockTimestamp is when the block holding the transaction was mined, as the chain reports it, in ISO-8601 UTC; createdAt is when we first saw the payment, and occurredAt is when the event was raised. blockNumber and blockTimestamp are null on invoice.payment_confirmed, so keep them from the invoice.payment_detected that came before it. A deposit first seen at its confirmation has no earlier event, and you never receive its block number or time over a webhook.

Conversion events carry the conversion

On conversion.completed, conversion.failed and conversion.skipped, data is the conversion, not an invoice:

{
  "event": "conversion.completed",
  "occurredAt": "2026-01-01T12:15:30.000Z",
  "data": {
    "id": "f7a8b9c0-d1e2-4345-a678-9b0c1d2e3f45",
    "shortId": "CNV-1042",
    "merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
    "source": "AUTO",
    "status": "COMPLETED",
    "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
    "srcTokenId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "dstTokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
    "amountIn": "10000000000000000",
    "amountOut": "25480000",
    "fee": "382200",
    "invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
    "staticWalletId": null,
    "sweepId": "a1b2c3d4-e5f6-4a78-9b01-2c3d4e5f6a78",
    "skipReason": null,
    "createdAt": "2026-01-01T12:15:30.000Z",
    "settledAt": "2026-01-01T12:15:30.000Z"
  }
}

The example is trimmed; the payload also carries the quote, the rates, the USD values and the transaction behind the conversion.

Field Meaning
source MANUAL — you converted part of your balance in the cabinet. AUTO — an auto-conversion rule converted a sweep on its way to your balance
status COMPLETED, FAILED (MANUAL only) or SKIPPED (AUTO only)
amountIn Base units of srcTokenId sold
amountOut Base units of dstTokenId delivered
fee AUTO only: what was withheld out of amountOut before the credit, in dstTokenId — the merchant fee and the network fee of the sweep
invoiceId, staticWalletId, sweepId AUTO only: the sweep that was converted and where its money came from
skipReason On SKIPPED: why the sweep went through unconverted — the same values as a sweep's conversionSkipReason

There is no route on the merchant API that reads a conversion back. The webhook, and the creditedTokenId, amountIn and amountOut of the sweep it converted, are all the API tells you.

Verifying the signature

Join the X-Nullswap-Timestamp value, a . and the raw request body, compute HMAC-SHA256(secret, timestamp + "." + 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. Use the timestamp exactly as it arrived in the header, without parsing and re-formatting it.

import { createHmac, timingSafeEqual } from "node:crypto"

const TOLERANCE_SECONDS = 300

// express.raw({ type: "application/json" }) — req.body must be the exact bytes we sent.
export function isValidSignature(rawBody, timestamp, header, secret) {
  if (!/^\d+$/.test(timestamp ?? "")) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
  const expected =
    "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")
  const a = Buffer.from(expected)
  const b = Buffer.from(header ?? "")
  return a.length === b.length && timingSafeEqual(a, b)
}
import hmac
import time
from hashlib import sha256

TOLERANCE_SECONDS = 300

def is_valid_signature(raw_body: bytes, timestamp: str, header: str, secret: str) -> bool:
    if not (timestamp or "").isdigit():
        return False
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 300

func IsValidSignature(rawBody []byte, timestamp, header, secret string) bool {
    sent, err := strconv.ParseUint(timestamp, 10, 63)
    if err != nil {
        return false
    }
    age := time.Now().Unix() - int64(sent)
    if age > toleranceSeconds || age < -toleranceSeconds {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    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.

Rejecting replays

A valid signature proves the request came from us, not that it was sent just now. Someone who gets hold of a delivery we sent you — from a proxy log, an error tracker or a request-inspection tool used while testing — can send it to you again, and its signature will still verify. The timestamp is what lets you tell such a copy from a fresh delivery: it is covered by the signature, so it cannot be changed without breaking it.

Reject a request whose X-Nullswap-Timestamp is more than 5 minutes away from your own clock, in either direction, as the samples above do. The tolerance is yours to choose. Keep your server's clock synchronised over NTP, or genuine deliveries start failing the check.

Every attempt is signed with the time it is sent, a retry included. A delivery retried an hour later arrives with a fresh timestamp, so the check never rejects one of our retries.

The timestamp check does not replace deduplication

A retry is a new attempt with a new timestamp and the same X-Nullswap-Delivery, and it can arrive after you have already processed the delivery — for example when your handler took longer than the timeout. Only deduplication and state-based handling, described under Retries, ordering and idempotency, make those 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 on their own

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. The only thing that ends the retries is cancelling the delivery while it is still pending: in the cabinet, under Settings → Webhooks → Deliveries, a merchant owner can select pending deliveries and cancel them. A cancelled delivery has status: CANCELLED and is not sent again unless it is retried from the same screen.

Because of that backlog, your handler needs two properties:

  • Deduplicate on X-Nullswap-Delivery. The same delivery id can arrive more than once. Record the id and ignore repeats. Keep the ids for as long as retries can reach you — retries do not stop on their own, so that means days, not minutes.
  • Act on state, not on receipt. Decide what to do from the payload, not from the fact that a request arrived: mark an order paid if it is not paid yet, credit a deposit once per data.payment.id. A handler written that way is safe even when the same event reaches it twice under two different delivery ids, as it does when two of your subscriptions point at the same URL.
  • 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 times out, and every event it handles is delivered again.

A minimal handler looks like this:

1. read the raw body
2. verify X-Nullswap-Signature and X-Nullswap-Timestamp, 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

The delivery history is not available over the merchant API. Read it in the cabinet, under Settings → Webhooks → Deliveries, when a hook you expected never arrived. It separates four cases that look identical from your side:

What you see What it means
No delivery for that invoice and event The event never fired
PENDING with attempts made and a last error We tried; your endpoint refused it, and we are still retrying
DELIVERED We sent it and got a 2xx; it was lost on your side
CANCELLED Your merchant's owner, or NullSwap support at your request, stopped it while it was still pending; we will not send it again unless it is retried

A failed attempt never makes a delivery FAILED: it goes back to PENDING with the error recorded and a retry scheduled. FAILED is only written when the subscription no longer existed at the moment of sending, and deleting a subscription deletes its delivery history with it, so you will rarely see one.

A cancel that lands while an attempt is already in flight still leaves the delivery CANCELLED, even if that last attempt reached you. CANCELLED means "will not be sent again", not "never arrived".

There is no redelivery endpoint on the merchant API

If you dropped a delivery you had already answered 2xx to, do not wait for a replay. Reconstruct the state by reading the invoice — GET /api/v1/invoices/{invoiceId} and GET /api/v1/invoices/{invoiceId}/payments. The payload was a snapshot of those anyway.