Authentication¶
Every request under /api/v1 carries your merchant API key in the x-api-key header. There is no
sign-in, no token exchange and no session to keep alive. The two endpoints outside that prefix,
/docs and /health, need no key.
curl https://api.pay.nullswap.com/api/v1/invoices?limit=20 \
-H "x-api-key: sk_live_2f8b1c0d4e5a69738f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e"
The key is sk_live_ followed by 64 hexadecimal characters. It is not a bearer token: an
Authorization: Bearer ... header is ignored, and the API will answer 401 as if no key had been
sent at all.
One key names one merchant¶
Your key identifies exactly one merchant. That has a consequence worth internalising before you read any other page:
No route anywhere in this API takes a merchant id
Not in a path, not in a query string, not in a request body. Every list is narrowed to your
merchant before anything is read, and every write is attributed to it. If you are looking for a
merchantId field to send, there isn't one.
merchantId does appear in responses — on an invoice, a webhook subscription, a static wallet — but
purely as an echo, and it will always be your own.
Read the merchant your key belongs to with:
{
"id": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"name": "Acme Store",
"status": "ACTIVE",
"createdAt": "2026-01-01T12:00:00.000Z",
"updatedAt": "2026-01-01T12:00:00.000Z"
}
What the key cannot do¶
Administering the merchant is not part of this API. Members, API keys, IP whitelists and webhook subscriptions are created and changed by a person in the cabinet. The API can read webhook subscriptions — including the secret you verify signatures with — but there is no route to create, edit or delete one.
Concretely, the merchant API has six write routes and nothing else:
| Method | Path |
|---|---|
POST |
/api/v1/invoices |
POST |
/api/v1/invoices/{invoiceId}/payments/{paymentId}/resolve |
POST |
/api/v1/invoices/{invoiceId}/payments/{paymentId}/refund |
POST |
/api/v1/static-wallets |
POST |
/api/v1/merchant/withdrawals |
POST |
/api/v1/aml/check-address |
There are no PUT, PATCH or DELETE routes.
Keep the key server-side¶
The key is a bearer credential in the plain sense: whoever holds it can create invoices, move your money out and read your entire payment history.
Never ship the key to a browser or a mobile app
Call the API from your backend only. If a key leaks, rotate it from the cabinet — a rotated key stops working on the next request, because keys are not cached.
IP whitelisting¶
A key can additionally be pinned to a set of source addresses (Settings → IP whitelist).
- An empty whitelist means no filtering — the key works from anywhere.
- Add a single entry and every other source address is refused with
403 CallerIpNotAllowedError, even though the key itself is perfectly valid.
The address we compare against is the TCP peer address of the connection. X-Forwarded-For is not
trusted and cannot be used to change it, so if your traffic leaves through a NAT or an egress proxy,
whitelist that address rather than your application server's.
Errors¶
| Status | message |
Cause |
|---|---|---|
401 |
InvalidApiKeyError |
Header missing, malformed, unknown or revoked. All four are deliberately indistinguishable. |
403 |
CallerIpNotAllowedError |
Valid key, presented from an address its whitelist does not cover. |
403 |
MerchantIsBlockedError |
The merchant has been blocked. |
403 |
PermissionDeniedError |
The resource you named belongs to another merchant. |
A blocked merchant cannot read either
MerchantIsBlockedError is raised during authentication, before the route runs, so it applies to
every authenticated endpoint — reads included. If you are treating 403 as "this write was
rejected", widen it.
Note also the distinction between 403 PermissionDeniedError and 404: asking for an invoice id
that belongs to a different merchant is a 403, not a 404. Asking for an id that exists nowhere is
a 404.