API conventions¶
Rules that hold across every endpoint. Reading this page once saves reading the same paragraph on nine others.
Shape of a request¶
- Base URL
https://api.pay.nullswap.com, with every endpoint under/api/v1. Two paths sit outside the version prefix — see below. - Request and response bodies are JSON. Send
Content-Type: application/jsonon everyPOST. - Field names are
camelCase. Enum values areUPPER_SNAKE_CASE, except webhook event names, which are lowercase and dotted (invoice.paid). - Identifiers are UUIDs.
- Timestamps are ISO-8601 with milliseconds in UTC:
2026-01-01T12:00:00.000Z. - Successful
POSTs answer201, successfulGETs answer200. There is no204anywhere.
Outside the version prefix¶
Two endpoints are not under /api/v1 and take no API key. Neither is versioned, so neither will move
when the API does.
| Path | What it is |
|---|---|
/docs |
The Swagger UI — an interactive reference for every route on this site |
/health |
A liveness check, answering {"status":"ok"} |
/docs is generated from the API itself rather than written by hand, so it is the place to
confirm an exact field name, an enum's permitted values or a response shape. It also lets you fire a
real request from the browser: press Authorize, paste your sk_live_ key, and the calls it makes
are ordinary authenticated calls counting against your merchant like any other. It is a reference,
not a tutorial — this site is the tutorial, and the two are worth reading together.
A key pasted into Swagger is a key used from your browser's address
An API key can be pinned to a list of allowed addresses, and a call made from the Swagger UI
comes from wherever you are sitting rather than from your server. If your key is whitelisted,
expect 403 CallerIpNotAllowedError until you try from an allowed address. See
Authentication.
/health reports whether the API is up. It says nothing about your merchant, and a healthy
answer does not imply your key works — poll it to tell "the platform is down" from "my request was
rejected", and nothing more.
Amounts¶
Every monetary value crosses this boundary as a decimal string of an integer in the token's base
units, paired with the decimals needed to render it. Never a JSON number.
| Token | Human amount | decimals |
On the wire |
|---|---|---|---|
| USDT (TRON) | 25.50 | 6 | "25500000" |
| ETH | 1.0 | 18 | "1000000000000000000" |
| BTC | 0.005 | 8 | "500000" |
The reason is exactness. 1000000000000000000 does not survive a JSON parser that stores numbers as
IEEE-754 doubles, and a payment API that silently rounds is worse than one that refuses to. Parse
these with a big-integer or arbitrary-precision decimal type on your side too — do not let them
become floats after they arrive.
Two values are scaled rather than base-unit encoded, and both say so where they appear:
| Field | Meaning |
|---|---|
TokenResponse.price |
USD price of one whole token, multiplied by 1018 |
TokensRateResponse.rate |
Destination units per source unit, multiplied by 1018 |
Sums are only comparable within one token
GET /api/v1/invoices/stats returns byToken[].expectedAmount and byToken[].paidAmount as base
units summed inside one bucket. They are not a currency and adding two buckets together produces
a meaningless number.
Pagination¶
Every list endpoint that can grow without bound answers with the same envelope:
{
"data": [ /* ... */ ],
"pagination": {
"offset": 0,
"limit": 50,
"total": 137,
"order": "DESC"
}
}
| Parameter | Type | Notes |
|---|---|---|
offset |
integer ≥ 0 | Rows to skip |
limit |
integer ≥ 1 | Rows to return |
order |
ASC | DESC |
Defaults to DESC |
sortBy |
enum | Defaults to CREATED_AT |
limit has no default and no maximum
Omit it and you get the entire result set in one response — every invoice you have ever raised.
Always send a limit.
Two exceptions to the defaults:
GET /api/v1/invoices/{invoiceId}/paymentsdefaults toorder=ASC. A payment history reads in the order it happened; every other list is newest-first.GET /api/v1/chains,GET /api/v1/tokensandGET /api/v1/merchant/balancesare not paginated. They answer with{ "data": [...] }or{ "items": [...] }and nopaginationobject, because their row counts are bounded by the catalogue rather than by your traffic.
sortBy accepts CREATED_AT and ID everywhere it is offered; GET /api/v1/invoices also accepts
EXPIRES_AT. An unsupported value is 400 UnsupportedSortByError.
Filtering¶
Array filters are repeated query parameters, and repeats are ORed together:
Different parameters are ANDed:
A single filter may carry at most 500 values. Beyond that the request is refused with
400 TooManyFilterValuesError — chunk your ids client-side.
Date filters (createdFrom, createdTo on GET /api/v1/invoices/stats) take ISO-8601 timestamps;
anything unparseable is 400 InvalidDateFilterError.
Errors¶
Errors carry the HTTP status and a stable, machine-readable name in message:
{
"statusCode": 409,
"message": "InvoiceWithExternalIdAlreadyExistsError",
"timestamp": "2026-01-01T00:00:00.000Z"
}
message is a symbol, not a sentence. Branch on it; never on wording, and rarely on the status alone
— a 422 can mean six different things depending on the route. Errors lists every name.
Retrying safely¶
Network timeouts happen. What matters is whether repeating a call repeats its effect.
| Call | Safe to retry? |
|---|---|
Any GET |
Yes, always |
POST /api/v1/static-wallets |
Yes — idempotent on reference; the same reference always returns the same address |
POST /api/v1/merchant/withdrawals |
Yes if you sent externalId. Without one, a retry pays out twice |
POST /api/v1/invoices |
Not idempotent. A repeat with the same externalId answers 409; see below |
POST .../resolve, POST .../refund |
A repeat answers 422 because the payment has already moved on |
externalId on an invoice is a guard, not an idempotency key
Repeating POST /api/v1/invoices with an externalId you have already used answers
409 InvoiceWithExternalIdAlreadyExistsError. It does not return the original invoice. That
is still the behaviour you want — it is what stops one order from opening two deposit addresses —
but your retry path has to handle it:
POST /api/v1/invoices -> 409 InvoiceWithExternalIdAlreadyExistsError
GET /api/v1/invoices?externalId=order-10241&limit=1 -> the invoice you already raised
On a withdrawal, externalId behaves the other way round: it is a true idempotency key and a
repeat answers with the withdrawal already made.
A 502 means an internal dependency failed before your request took effect. Those are safe to retry
with backoff. A 500 from the gateway means the upstream call timed out or was unreachable and the
outcome is genuinely unknown — retry a read, but for a write, reconcile first (GET the resource, or
look it up by your externalId) before sending it again.
Rate limits and headers¶
The API does not currently return 429, and it emits no X-RateLimit-*, Retry-After or
correlation-id headers. Treat that as today's behaviour rather than a promise: keep client
concurrency sane, back off on 5xx, and do not build a design that depends on unlimited request
volume.
CORS is configured for Content-Type and x-api-key only, with no credentials. That is incidental —
the API is meant to be called from your server, not from a page.