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 which applies to every 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 or 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",
"feePercentage": "150"
}
feePercentage is the merchant fee we charge on your payments, in basis points ("150" is 1.50%).
We set it; there is no route to change it. It is withheld from each sweep onto your balance — see
the merchant fee.
What the key cannot do¶
Administering the merchant is not part of this API. Members, API keys, IP whitelists, the withdrawal address whitelist, auto-conversion rules and webhook subscriptions are created and changed by a person in the cabinet. The API has no webhook routes at all: it cannot create, edit, delete or read a subscription, its delivery history or the secret you verify signatures with, which you read in the cabinet.
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/wallets/{walletId}/sweep |
POST |
/api/v1/merchant/withdrawals |
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.
A merchant has one key at a time, under Settings → API key in the cabinet. A new merchant has none: its owner issues the first one with Issue a key. Only the owner can Regenerate it, which revokes the old key at once. Issuing and regenerating ask for a two-factor code when the owner has two-factor authentication turned on.
The key is shown once
The cabinet shows the full key only right after you issue or regenerate it. Copy it into your
backend's secret storage then: we keep only its last four characters, so afterwards every member
sees it masked (••••1d0e) and nobody, us included, can show it again. If you lose it, regenerate
it and update your integrations.
IP whitelisting¶
API access can additionally be restricted to a set of source addresses, on the API access by IP card under Settings → API key. The list belongs to the merchant, not to the key, so it survives a key rotation. Only the owner can change it. Settings → Whitelists is a different list: the withdrawal addresses.
- An empty whitelist means no filtering — the key works from anywhere.
- Add a single entry and every other source address is refused with
403 CALLER_IP_NOT_ALLOWED, even though the key itself is perfectly valid. - An entry is an IPv4 address in which any octet may be
*(203.0.113.*), an exact IPv6 address, or a lone*for any address. CIDR ranges are not accepted, and IPv6 takes no wildcards.
A blocked merchant is refused with MERCHANT_BLOCKED before its whitelist is consulted.
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 |
INVALID_API_KEY |
Header missing, malformed, unknown or revoked. All four are deliberately indistinguishable. |
403 |
CALLER_IP_NOT_ALLOWED |
Valid key, presented from an address its whitelist does not cover. |
403 |
MERCHANT_BLOCKED |
The merchant has been blocked. |
403 |
PERMISSION_DENIED |
The resource you named belongs to another merchant. |
A blocked merchant cannot read either
MERCHANT_BLOCKED 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 PERMISSION_DENIED 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.