Skip to content

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/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. There is no 204 anywhere.

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}/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 UnsupportedSortByError.

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 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.