API conventions¶
Rules that hold across every endpoint. They are collected here so that the other pages do not have to repeat them.
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. The one exception isPOST /api/v1/wallets/{walletId}/sweep, which answers202: the sweeps it starts finish later. There is no204anywhere. Treat any2xxas success rather than comparing against201. - Unknown fields and parameters are refused, not ignored. A body field the endpoint does not
describe is a
400 INVALID_REQUEST_BODY, an extra query parameter a400 INVALID_QUERY_PARAMETER. That way a typo in a filter name, or a field you believed was applied, never passes silently. Values are checked strictly by type: an amount is a string only, and a number in its place is refused. - A request's shape is checked before the key: a malformed request with a bad key gets
400, not401.
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 route-level
reference rather than a walkthrough; the flows themselves are described on this site.
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 CALLER_IP_NOT_ALLOWED 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. Use it only to tell a platform outage apart from a rejected
request.
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: it comes back rounded, with no error to tell you. Parse these with a big-integer or
arbitrary-precision decimal type on your side too — do not let them become floats after they arrive.
A few values are scaled rather than base-unit encoded, and each says so where it appears:
| Field | Meaning |
|---|---|
TokenResponse.price |
USD price of one whole token, multiplied by 1018 |
TokensRateResponse.rate |
Destination units per source unit, multiplied by 1018 |
feeUsd |
A USD amount multiplied by 1018: "22500000000000000" is $0.0225 |
feePercentage |
A rate in basis points, a decimal string of a whole number: "150" is 1.50% |
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.
A negative or non-numeric offset or limit is not an error: it is ignored as if you had not sent
it, so a typo in limit also returns everything. order is matched exactly, and any value other than
ASC or DESC — desc included — is a 400 INVALID_QUERY_PARAMETER. In the response, pagination.offset and
pagination.limit are null when you did not send them.
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 UNSUPPORTED_SORT_BY.
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 TOO_MANY_FILTER_VALUES — chunk your ids client-side.
Date filters (settledFrom, settledTo on GET /api/v1/merchant/fees) take ISO-8601 timestamps;
anything unparseable is 400 INVALID_DATE_FILTER.
A single-valued parameter (order, sortBy, limit and so on) sent twice is a
400 INVALID_QUERY_PARAMETER. An empty value (?chainId=) counts as not sent.
Errors¶
Errors carry the HTTP status and a stable, machine-readable error code in message:
{
"statusCode": 409,
"message": "INVOICE_EXTERNAL_ID_ALREADY_EXISTS",
"timestamp": "2026-01-01T00:00:00.000Z"
}
message is a code, not a sentence. Branch on it rather than on the wording or on the status
alone: a 422 can mean six different things depending on the route. Errors lists every
code.
Retrying safely¶
Any call can time out. What you need to know is whether repeating it repeats its effect.
| Call | Safe to retry? |
|---|---|
Any GET |
Yes, always |
POST /api/v1/static-wallets |
Yes — idempotent on chainType and reference; the same pair always returns the same address |
POST /api/v1/wallets/{walletId}/sweep |
Yes — a sweep already running is returned rather than started twice |
POST /api/v1/merchant/withdrawals |
Yes if you sent externalId (optional, strongly recommended): a repeat with the same externalId and the same parameters returns the withdrawal already made, different parameters answer 409. 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 INVOICE_EXTERNAL_ID_ALREADY_EXISTS. 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 INVOICE_EXTERNAL_ID_ALREADY_EXISTS
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 with the same parameters answers with the withdrawal already made.
A 5xx answers INTERNAL_ERROR and does not tell you whether your request took effect: a 502 in
particular also covers a call that timed out inside the platform. Retry a read with backoff. Retry a
write that is idempotent by the table above; for any other 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. It is listed here for
reference only: the API is meant to be called from your server, not from a page.