Skip to content

Invoices

An invoice is a request for payment tied to one order. Creating one reserves a deposit address on the chain you chose; anything sent to that address is attributed to that invoice and to no other.

Create an invoice

curl -X POST https://api.pay.nullswap.com/api/v1/invoices \
  -H "x-api-key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
        "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
        "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
        "expectedAmount": "25500000",
        "expiresInSeconds": 3600,
        "externalId": "order-10241"
      }'
Field Type Required Notes
chainId UUID yes The chain the customer will pay on. From GET /api/v1/chains
tokenId UUID yes Must belong to chainId, or the call is 404 TokenWithIdNotFoundError
expectedAmount decimal string yes Strictly positive, in the token's base units
expiresInSeconds integer yes 60604800 (1 minute – 7 days)
externalId string | null no Your own order reference. Unique per merchant
acceptsAnyToken boolean no Default false. See Alternative tokens

There is no merchantId field — your API key already names the merchant.

The response is the invoice:

{
  "id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
  "merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
  "depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
  "depositAddress": "TQ5s...9Xk2",
  "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
  "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
  "expectedAmount": "25500000",
  "acceptsAnyToken": false,
  "paidAmount": "0",
  "pendingAmount": "0",
  "externalId": "order-10241",
  "status": "WAITING",
  "amlStatus": null,
  "createdAt": "2026-01-01T12:00:00.000Z",
  "expiresAt": "2026-01-01T13:00:00.000Z",
  "updatedAt": "2026-01-01T12:00:00.000Z"
}

Show depositAddress and the exact expectedAmount to your customer, and stop showing them once expiresAt has passed.

Always send externalId

It is what stops one order from opening two deposit addresses when a create call times out and your client retries. A repeat answers 409 InvoiceWithExternalIdAlreadyExistsError rather than creating a second invoice — recover by looking the original up with GET /api/v1/invoices?externalId=order-10241&limit=1. It is also how you find the invoice again later without storing our id.

Errors

Validation runs in this order, so the first failing rule is the error you get:

Status message Cause
400 InvalidRequestBodyError A field is missing or of the wrong JSON type
403 MerchantIsBlockedError The merchant is blocked
422 InvalidInvoiceExpirationError expiresInSeconds outside 60604800
422 InvalidInvoiceAmountError expectedAmount is not a positive base-unit integer string
409 InvoiceWithExternalIdAlreadyExistsError You have already used that externalId
404 ChainWithIdNotFoundError Unknown chainId
404 TokenWithIdNotFoundError Unknown tokenId, or the token is not on that chain
502 WalletServiceError A deposit address could not be reserved. Safe to retry

Reading invoices

Method Path Purpose
GET /api/v1/invoices/{invoiceId} One invoice
GET /api/v1/invoices List, with filters and pagination
GET /api/v1/invoices/stats Aggregate counts and volumes
GET /api/v1/invoices/{invoiceId}/payments The individual on-chain deposits — see Invoice payments

GET /api/v1/invoices filters on id, chainId, tokenId, status and externalId, all repeatable, plus offset, limit, order and sortBy (CREATED_AT, EXPIRES_AT, ID).

GET /api/v1/invoices?status=PAID&status=PARTIALLY_PAID&limit=100&sortBy=EXPIRES_AT&order=ASC

GET /api/v1/invoices/stats takes chainId, tokenId, createdFrom and createdTo, and returns counts bucketed three ways:

{
  "total": 137,
  "byStatus": [{ "status": "PAID", "count": 96 }],
  "byToken": [{
    "chainId": "1c9a7f36-...",
    "tokenId": "3b7e1a48-...",
    "count": 96,
    "expectedAmount": "2448000000",
    "paidAmount": "2451300000"
  }],
  "byDay": [{ "date": "2026-01-01", "count": 12, "paidCount": 9 }]
}

Polling is the fallback, not the integration

Subscribe to webhooks and treat a poll as the reconciliation job you run when your endpoint has been down. There is no long-polling and no streaming endpoint.

Status lifecycle

stateDiagram-v2
    direction LR
    [*] --> WAITING

    WAITING --> PAYMENT_DETECTED
    WAITING --> PARTIALLY_PAID
    WAITING --> PAID
    WAITING --> EXPIRED

    PAYMENT_DETECTED --> WAITING
    PAYMENT_DETECTED --> PARTIALLY_PAID
    PAYMENT_DETECTED --> PAID
    PAYMENT_DETECTED --> EXPIRED

    PARTIALLY_PAID --> WAITING
    PARTIALLY_PAID --> PAYMENT_DETECTED
    PARTIALLY_PAID --> PAID
    PARTIALLY_PAID --> EXPIRED

    PAID --> SWAPPING
    SWAPPING --> COMPLETED
    SWAPPING --> FAILED

    COMPLETED --> [*]
    FAILED --> [*]
    EXPIRED --> [*]
Status What it means
WAITING Created, nothing seen on chain yet
PAYMENT_DETECTED A transaction is visible but not yet confirmed or screened
PARTIALLY_PAID Cleared money arrived, but paidAmount is still under expectedAmount
PAID paidAmount >= expectedAmountthis is the one to fulfil on
SWAPPING The funds are being swapped for settlement
COMPLETED Settled to your balance
FAILED Settlement failed
EXPIRED expiresAt passed without sufficient payment

Three things the diagram is saying that are easy to miss:

  • Statuses can go backwards, but only before PAID. A transaction that is reorged away takes its amount with it, and the invoice drops from PARTIALLY_PAID back to WAITING. Your handler must treat every webhook payload as a snapshot, not as a diff.
  • PAID only ever leads to SWAPPING. Once an invoice is paid it can no longer expire and no longer partially unpays. Money that arrives afterwards is recorded as an unexpected payment and does not change paidAmount.
  • SWAPPING, COMPLETED and FAILED are driven by settlement, not by you. There is no merchant-facing route that triggers them; you observe them through webhooks or by reading the invoice.

COMPLETED, FAILED and EXPIRED are terminal. Amounts can still be corrected afterwards, so a late transaction on an expired invoice is recorded rather than lost — but the status will not move again.

The two amounts

pendingAmount and paidAmount move independently, and confusing them is the difference between shipping goods and giving them away.

Counts money that is… Compared against expectedAmount?
pendingAmount seen on chain, not yet confirmed and not yet screened no
paidAmount confirmed and cleared screening yes

Only payments in the invoice's own chain and token contribute to either figure. Deposits in another token never do, whatever you decide to do with them. The status is then derived from the two:

paidAmount >= expectedAmount   -> PAID
paidAmount > 0                 -> PARTIALLY_PAID
pendingAmount > 0              -> PAYMENT_DETECTED
otherwise                      -> WAITING

pendingAmount is not money you have

It is money you can see. It becomes yours only when it moves into paidAmount. An invoice can never reach PAID on funds that failed screening — flagged money is removed from pendingAmount and is never added to paidAmount.

Under- and overpayment

The invoice does not reject a wrong amount; it records what actually arrived.

Underpayment. The invoice sits at PARTIALLY_PAID. The customer can top it up before expiresAt and several deposits sum together. If the deadline passes while it is still short, it expires with paidAmount recorded — the money is not lost, but the invoice is closed and you decide what to do.

Overpayment. The invoice becomes PAID with paidAmount > expectedAmount. There is no cap, no OVERPAID status and no automatic refund. Compare the two yourself if you refund the difference.

Check the amount, not just the status

PAID means "at least the expected amount cleared". If your fulfilment depends on the exact figure, read paidAmount.

Expiry

An invoice expires when expiresAt passes — but not while money is still in flight. Expiry is skipped for an invoice that has any deposit still awaiting confirmation, or a confirmed deposit in an alternative token awaiting your decision. A customer who pays thirty seconds before the deadline does not lose their payment to a confirmation that lands thirty seconds after it.

A PARTIALLY_PAID invoice does expire. Partial payment stops the clock only for as long as something is still confirming.

Alternative tokens

Set acceptsAnyToken: true and a deposit of a different token on a compatible chain is recorded as an ALTERNATIVE payment instead of being treated as a mistake. It still does not count towards paidAmount, because expectedAmount is denominated in the invoice's own token — you decide what it is worth and whether the order is settled.

Leave it false (the default) and such a deposit becomes an UNEXPECTED payment instead. Either way the money is recorded; the flag only changes how it is classified and whether you are asked to decide about it. See Invoice payments.