Skip to content

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/json on every POST.
  • Field names are camelCase. Enum values are UPPER_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 answer 201, successful GETs answer 200. The one exception is POST /api/v1/wallets/{walletId}/sweep, which answers 202: the sweeps it starts finish later. There is no 204 anywhere. Treat any 2xx as success rather than comparing against 201.
  • 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 a 400 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, not 401.

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}/payments defaults to order=ASC. A payment history reads in the order it happened; every other list is newest-first.
  • GET /api/v1/chains, GET /api/v1/tokens and GET /api/v1/merchant/balances are not paginated. They answer with { "data": [...] } or { "items": [...] } and no pagination object, 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:

GET /api/v1/invoices?status=PAID&status=PARTIALLY_PAID&limit=100

Different parameters are ANDed:

GET /api/v1/tokens?status=ENABLED&symbol=USDT

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.