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 TOKEN_NOT_FOUND, and accept payments (srcStatus: ENABLED), or it is 422 TOKEN_IS_DISABLED
expectedAmount decimal string yes Strictly positive, in the token's base units
expiresInSeconds integer yes 60–604800 (1 minute – 7 days)
externalId string | null no Your own order reference. Unique per merchant
acceptsAnyToken boolean no Default false. See Alternative tokens
autoSweep boolean no Default true. See Moving funds to your balance

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",
  "depositTag": null,
  "depositXAddress": null,
  "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
  "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
  "expectedAmount": "25500000",
  "acceptsAnyToken": false,
  "autoSweep": true,
  "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. On RIPPLE show depositTag with them, marked as mandatory: there the address alone does not name the invoice. See Destination tags on RIPPLE. depositTag and depositXAddress are null on every other chain.

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 INVOICE_EXTERNAL_ID_ALREADY_EXISTS 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
403 MERCHANT_BLOCKED The merchant is blocked. Checked with your key, before the body is read
400 INVALID_REQUEST_BODY A field is missing or of the wrong JSON type, e.g. autoSweep that is not a boolean
422 INVALID_INVOICE_EXPIRATION expiresInSeconds outside 60–604800
422 INVALID_INVOICE_AMOUNT expectedAmount is not a positive base-unit integer string
409 INVOICE_EXTERNAL_ID_ALREADY_EXISTS You have already used that externalId
404 CHAIN_NOT_FOUND Unknown chainId
404 TOKEN_NOT_FOUND Unknown tokenId, or the token is not on that chain
422 TOKEN_IS_DISABLED The token does not accept payments right now: its srcStatus is DISABLED. See tokens
502 INTERNAL_ERROR A deposit address could not be reserved. Safe to retry

Destination tags on RIPPLE

On RIPPLE, the XRP Ledger, a deposit address is not yours alone. Every RIPPLE invoice is paid to the same depositAddress, and a payment is matched to its invoice by the destination tag it carries, and by nothing else. The invoice names that tag in two more fields:

Field Type Notes
depositTag integer | null The destination tag the payment must carry, 0–4294967295. Tags are not sequential: show the one returned, never derive one
depositXAddress string | null depositAddress and depositTag packed into one X-address (X…), for wallets that take an address and no tag. Paying to it is paying to the pair
{
  "id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
  "depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
  "depositAddress": "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3",
  "depositTag": 959725,
  "depositXAddress": "XVMFQQBMhdouRqhPMuawgBMN1AVFToG5NjwwoxoAYuiUyDF",
  "expectedAmount": "25000000",
  "status": "WAITING"
}

Show your customer the address, the tag — labelled as required, not as an optional memo — and the exact amount. You may also show depositXAddress as an alternative to the pair, and it is what a QR code should encode: a wallet that scans it gets the address and the tag together. Every invoice.* webhook carries both fields in data as well.

Without the right tag the payment does not reach the invoice

A payment to depositAddress with no destination tag is refused by the network, and the customer's wallet reports it as failed. A payment with a tag other than depositTag is not credited to this invoice: a tag we never issued is held and is not credited to any invoice automatically — contact support with the transaction hash. Exchange withdrawal forms often label the tag field "optional"; tell your customer it is not.

The amount credited is what the payment delivered to the address, not the amount it named: an XRP Ledger payment can be sent as a partial payment, and only what arrived counts. Under- and overpayment then work as on any other chain — see Under- and overpayment.

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/{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

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 --> WAITING
    PAID --> PAYMENT_DETECTED
    PAID --> PARTIALLY_PAID

    WAITING --> SWAPPING
    PAYMENT_DETECTED --> SWAPPING
    PARTIALLY_PAID --> SWAPPING
    PAID --> SWAPPING
    SWAPPING --> COMPLETED
    SWAPPING --> FAILED
    FAILED --> SWAPPING
    FAILED --> COMPLETED

    COMPLETED --> [*]
    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 >= expectedAmount — this is the one to fulfil on
SWAPPING A sweep of the deposit address has started and its money is on its way to your balance
COMPLETED No sweep of the deposit address is still running or failed: the money is on your balance
FAILED A sweep of the deposit address gave up. The money is still on the address, and a later sweep picks it up
EXPIRED expiresAt passed without sufficient payment

Four details in the diagram are easy to miss:

  • Statuses can go backwards, PAID included. A transaction that is reorged away takes its amount with it: the invoice drops from PARTIALLY_PAID back to WAITING, and a PAID invoice drops back to WAITING, PAYMENT_DETECTED or PARTIALLY_PAID, whichever its remaining amounts give. Your handler must treat every webhook payload as a snapshot, not as a diff.
  • Going forward, PAID leads only to SWAPPING. While an invoice is paid it cannot expire, and money that arrives meanwhile is recorded as an unexpected payment and does not change paidAmount. Only a reverted payment takes it out of PAID; once a sweep has started, a reverted payment no longer moves the status backwards.
  • SWAPPING, COMPLETED and FAILED follow sweeps, not payment. An automatic sweep moves a PAID invoice to SWAPPING once the sweep is accepted; a partially paid invoice keeps waiting for the rest. Calling the sweep endpoint yourself moves the invoice to SWAPPING as soon as the call puts money in flight — from WAITING, PAYMENT_DETECTED and PARTIALLY_PAID as well as from PAID. Either way the invoice becomes COMPLETED once no sweep of the address is still running or failed; dust left on it is not waited for.
  • FAILED is not final. When a sweep of a SWAPPING invoice gives up after its retries, the invoice becomes FAILED and you receive invoice.failed. The money is still on the deposit address: the next sweep — automatic, or one you request — moves the invoice back to SWAPPING, or straight to COMPLETED when it settles.

invoice.paid can be taken back

A chain reorganisation can revert a payment after invoice.paid was sent. The invoice then leaves PAID, you receive invoice.payment_failed (and invoice.partially_paid if it lands in PARTIALLY_PAID), and invoice.paid fires again if the invoice is paid again. If what you deliver on invoice.paid cannot be undone, check the invoice is still PAID before you release it.

COMPLETED does not mean paid

A sweep you request moves an invoice to SWAPPING whatever it was paid, so a COMPLETED invoice can have a paidAmount under expectedAmount, or "0" when only a deposit in another token was swept. Once you sweep, the status stops telling you whether the order was paid: decide that from paidAmount before you call the sweep.

COMPLETED 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 mean different things.

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 has only been seen on chain. It becomes yours 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 any confirmed deposit in an alternative token. A customer who pays just before the deadline therefore does not lose the payment because its confirmation arrived just after.

A confirmed alternative-token deposit stops expiry for good

The rule does not look at whether you have resolved the payment. An invoice that has received a confirmed ALTERNATIVE payment stays in its status past expiresAt and never sends invoice.expired.

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.

Moving funds to your balance

Money paid into an invoice arrives on its deposit address. It reaches your balance, and becomes something you can withdraw, only when it is swept off that address. A sweep moves one token's whole clean amount on the address in one go, so several payments in the same token travel together.

autoSweep on the invoice decides who starts it:

autoSweep What happens to a confirmed, clean payment
true (default) It is swept to your balance automatically, as soon as it has confirmed and cleared screening
false It stays on the deposit address until you sweep it yourself

Every payment that has confirmed and cleared screening is swept, whatever its kind — EXPECTED, ALTERNATIVE and UNEXPECTED alike, and whether or not you have resolved it. Money that failed screening is never swept and never reaches your balance; it stays on the deposit address.

Sweeps are retried without anything on your side. One that could not be started is started again until it is accepted, and one that failed is raised again a limited number of times, so a temporary outage delays the money rather than stranding it. If a sweep has still not gone through, call the sweep endpoint yourself.

An automatic sweep moves a PAID invoice to SWAPPING once it is accepted, and to COMPLETED once the money is on your balance. An invoice that is not yet fully paid keeps its status while its first payments are swept, and moves on only after it becomes PAID. A sweep that gives up turns a SWAPPING invoice FAILED until a later sweep moves the money; see the status lifecycle.

Set autoSweep: false when you want to choose the moment yourself — for example, to keep a payment you may resolve as REFUNDED off your balance until you have decided. The sweep is the only way money leaves a deposit address: NullSwap does not send refunds, so with false the funds stay exactly where they are until you sweep them.

Dust stays on the address

An amount worth no more than the network fee of moving it is not swept: sweeping it would cost more than it is worth. It stays on the deposit address and goes along with the next sweep of the same token there. On an invoice address a further payment rarely comes, so a dust remainder there usually stays.

Moving money costs a network fee, and you pay it either way. On a chain's native coin the sweep pays its own network fee out of what it moves, so slightly less than was paid arrives. For a token on an account-based chain we pay the network fee in the native coin and withhold its value, in the token at current prices, from the credit — together with the cost of any approvals a converting sweep needs and of the native coin sent to the address to pay for the move. On UTXO chains and on Monero nothing is withheld beyond what the sweep itself pays. Of what arrives, the merchant fee and that network fee are withheld, and the rest is credited to your balance.

The network fee is not a field of the sweep

The sweep reports amount and fee, but not the network fee withheld next to fee, so amount − fee overstates the credit on a token sweep. What actually reached your balance is credited in the list of fees withheld.

Sweep a deposit address

curl -X POST https://api.pay.nullswap.com/api/v1/wallets/7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a/sweep \
  -H "x-api-key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
        "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
        "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908"
      }'

walletId in the path is the invoice's depositWalletId, or the id of a static wallet. The body is optional:

Field Type Required Notes
chainId UUID no Sweep only tokens on this chain
tokenId UUID no Sweep only this token

Without a body, every token with a clean amount on the address is swept. The answer is 202 with one sweep per token:

{
  "data": [
    {
      "id": "0b1c2d3e-4f50-4617-8a29-3b4c5d6e7f80",
      "walletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
      "invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
      "staticWalletId": null,
      "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
      "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
      "trigger": "MANUAL",
      "status": "SWEEPING",
      "amount": "25500000",
      "feeEstimate": null,
      "transferRequestId": "4d5e6f70-8192-4a3b-9c4d-5e6f708192a3",
      "error": null,
      "createdAt": "2026-01-01T12:10:00.000Z",
      "updatedAt": "2026-01-01T12:10:01.000Z",
      "feePercentage": "150",
      "fee": null,
      "feeUsd": null,
      "settledAt": null,
      "creditedTokenId": null,
      "dstTokenId": null,
      "amountIn": null,
      "amountOut": null,
      "conversionSkipReason": null
    }
  ]
}
Field Meaning
invoiceId, staticWalletId Which kind of address was swept. Exactly one is set
trigger AUTO — started automatically; MANUAL — started by this endpoint
amount Base units of tokenId being moved, then what arrived once SWEPT — in creditedTokenId. null until accepted
feeEstimate Network fee in base units of the chain's native coin. Set only for dust
transferRequestId Our reference for the underlying transfer. Quote it to support
error Why the sweep failed, when it did, as a code: SWEEP_SOURCE_NOT_SUPPORTED (this address cannot be swept), SWEEP_FUNDS_UNAVAILABLE (the money is no longer there to sweep) or SWEEP_FAILED (anything else). Treat an unknown code as SWEEP_FAILED
feePercentage The merchant fee rate this sweep is charged, in basis points, fixed when it was opened
fee The merchant fee withheld out of amount, in base units of creditedTokenId. null until SWEPT
feeUsd fee in USD, multiplied by 1018. null until it has been priced
settledAt When the sweep reached SWEPT. null before that
creditedTokenId The token amount and fee are in once SWEPT: tokenId, or dstTokenId when the sweep was converted. null before that
dstTokenId The target of your auto-conversion rule, copied when the sweep was opened. null without a rule
amountIn On a converted sweep, base units of tokenId sold
amountOut On a converted sweep, base units of dstTokenId delivered
conversionSkipReason Why a sweep covered by a rule went through unconverted. null when it was converted or no rule covered it

Auto-conversion rules are set in the cabinet, under Convert → Auto-convert. A sweep covered by one is converted into the rule's target token on its way to your balance, when it can be. When it cannot, it is credited in its own token and conversionSkipReason says why:

conversionSkipReason Why the sweep was not converted
NO_VENUE There is nowhere to swap the token
NO_QUOTE The token has no usable price right now
BELOW_MINIMUM The amount is too small to convert
ABOVE_LIQUIDITY_LIMIT The amount is more than the market takes within its price-impact limit
GAS_TOO_HIGH The swap's network fee would cost too large a share of the amount
SLIPPAGE The live quote was below the floor your slippage allows
PRICE_DEVIATION The live quote was too far from the market price
SWAP_FAILED The swap failed on chain, and the sweep was retried without converting
TARGET_NOT_HEDGE The rule's target token can no longer be converted into
MERCHANT_BLOCKED The merchant was blocked when the sweep was opened
status Meaning
PENDING Recorded, not yet accepted for transfer. It is retried automatically
SWEEPING Accepted; the money is on its way to your balance
SWEPT Credited to your balance: amount − fee, less the network fee on a token sweep. Terminal
FAILED The transfer failed or was refused; read error. Retried automatically a limited number of times, unless the address itself is the problem
DUST Too little to move. Never returned by this endpoint — that case answers 422 instead
SUPERSEDED A FAILED or DUST sweep whose money a later sweep of the same address and token moved. That later sweep credited it; nothing was credited under this one, so never count its amount. Terminal. Not returned by this endpoint, which answers only with sweeps it started or found still active

A sweep can already be FAILED in the 202 itself, when it was refused the moment it was started. The 202 means the call was taken, not that every sweep in it is moving: read each status.

There is at most one active (PENDING or SWEEPING) sweep per address and token. If one is already running — an automatic one, or an earlier call of yours — it is returned as it is, and a new sweep is started only for the tokens that have none. That is not an error: trigger: "AUTO" in the answer to your call means the money was already on its way. Repeating the call is therefore safe.

There is no route that reads a sweep in flight

No endpoint returns a sweep by id, and no webhook reports one unless an auto-conversion rule covers it — then conversion.completed or conversion.skipped carries its sweepId. Once the answer says SWEEPING, watch your balance. Calling this endpoint again returns the sweep while it is still active; once it has finished, a further call finds nothing to move. A sweep that has settled appears, under the same id, in the list of fees withheld; a failed one appears nowhere.

When the address holds nothing clean above dust, the call answers 422 NOTHING_TO_SWEEP and adds a details object with the estimate, so you can see how far short the amount is:

{
  "statusCode": 422,
  "message": "NOTHING_TO_SWEEP",
  "timestamp": "2026-01-01T12:10:00.000Z",
  "details": {
    "amount": "150000",
    "feeEstimate": "2800000",
    "dust": [
      {
        "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
        "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
        "amount": "150000",
        "feeEstimate": "2800000"
      }
    ]
  }
}

amount is in base units of the token, feeEstimate in base units of the chain's native coin; the top-level pair repeats the first entry of dust. With no clean amount at all, dust is empty, amount is "0" and feeEstimate is null.

Status message Cause
400 INVALID_PATH_PARAMETER walletId is not a UUID
400 INVALID_REQUEST_BODY chainId or tokenId is present but not a UUID
403 MERCHANT_BLOCKED The merchant is blocked. Automatic sweeps of money already received carry on; only this call is refused
404 WALLET_NOT_FOUND No such address, or it belongs to another merchant
409 WALLET_HAS_NO_OWNER The address is not attributed to any merchant, so there is nowhere to credit it. Contact support; retrying will not help
422 NOTHING_TO_SWEEP Nothing clean above dust on the address. details carries the estimate
422 SWEEP_AMOUNT_TOO_SMALL Not expected here: dust is answered with NOTHING_TO_SWEEP. If you do see it, treat it the same way
502 INTERNAL_ERROR What is on the address could not be read. Nothing was started; safe to retry
502 INTERNAL_ERROR The tokens on the address could not be looked up. Nothing was started; safe to retry

Unlike the rest of the API, an address of another merchant is a 404, not a 403: the answer does not confirm that the id exists.