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_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 of its own for a status moving backwards. A reorg that drops an invoice from
PARTIALLY_PAIDtoWAITING, or out ofPAID, sendsinvoice.payment_failed; there is noinvoice.unpaid. The new status is in the payload. A drop fromPAIDtoPARTIALLY_PAIDalso sendsinvoice.partially_paid, queued before theinvoice.payment_failed. An invoice that is paid again after that sendsinvoice.paida second time. invoice.partially_paidcan 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 areconversion.completedandconversion.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.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 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.