Scope
invoices:writeto create, switch and cancel;invoices:readto list and read.
Create an invoice#
/invoices
Authorization: Bearer cv2_<mode>_<prefix>_<secret>
Content-Type: application/json
Idempotency-Key: order_8823Idempotency-Key is required; without it the request is refused 400 VALIDATION_ERROR ("Idempotency-Key header is required"). See Idempotency.
| Field | Type | Required | Description |
|---|---|---|---|
chainId |
string | Yes | A chain_id from GET /chains — "1", "bitcoin-mainnet", "tron-mainnet"… |
assetType |
"native" | "erc20" |
Send it | "native" for the network's own coin (ETH, BTC, TRX, XRP…), "erc20" for a token (TRC-20 on Tron too). ⚠️ It defaults to "erc20", so a coin invoice must say "native" |
assetContract |
string | For a token | The token's contract_address from GET /chains/:chainId/assets. On Tron, its 0x hex form — USDT on Tron is 0xa614f803b6fd780986a42c78ec9c7f77e6ded13c |
amountDecimal |
string | Yes | The amount as a person writes it: "0.01", "100", "49.99" |
expiresInSeconds |
number | Yes | How long the invoice stays open: 300 (5 minutes) to 86400 (24 hours) |
metadata |
object | No | Your own key-value data. Put your order id in external_ref, as a string — it comes back as external_ref on the invoice and as externalRef on every webhook |
customerId |
string (UUID) | No | Attach the invoice to one of your customers |
amountRaw |
string | — | Deprecated — the amount in base units. Send amountDecimal instead. Exactly one of the two is accepted |
100 USDC on Ethereum:
{
"chainId": "1",
"assetType": "erc20",
"assetContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"amountDecimal": "100",
"expiresInSeconds": 900,
"metadata": { "external_ref": "order_8824" }
}Response 201:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchantId": "uuid",
"chainId": "11155111",
"assetType": "native",
"assetContract": null,
"receiveType": "POOL",
"receiveAddress": "0xabc...def",
"receiving_address": "0xabc...def",
"receiveTag": null,
"external_ref": "order_8823",
"expectedAmount": "10000000000000000",
"status": "CREATED",
"displayStatus": "pending",
"expiresAt": "2026-05-30T12:30:00.000Z",
"minConfirmations": 2,
"createdAt": "2026-05-30T12:00:00.000Z",
"metadata": { "external_ref": "order_8823" },
"customerId": null,
"instructions": {
"chainId": "11155111",
"to": "0xabc...def",
"tag": null,
"amount": "10000000000000000",
"assetType": "native",
"assetContract": null
}
}| Field | Meaning |
|---|---|
receiveAddress |
Where the payer sends the money. receiving_address is the same value, kept for older integrations |
receiveTag |
XRP Ledger only: the destination tag the payer must include. null everywhere else |
expectedAmount |
The amount to pay, in base units |
instructions |
The same destination as one object — to, tag, amount, asset — ready to hand to a wallet |
minConfirmations |
The credit depth: blocks until invoice.paid |
status / displayStatus |
See The invoice lifecycle |
Why a create can be refused — each answer carries a code and a message saying what to do:
| Status | code |
Meaning |
|---|---|---|
422 |
ASSET_NOT_ACCEPTED |
That currency is not in your list in Account → Payment currencies |
422 |
CHAIN_NOT_IN_PLAN |
That network is not in your plan |
422 |
ENVIRONMENT_MISMATCH |
A test network with a Live key (or in Live mode), or a main network with a Test key (or in Test mode) |
400 |
BTC_XPUB_MISSING / BTC_XPUB_UNVERIFIED |
A Bitcoin invoice needs your Bitcoin account key, added and confirmed in the dashboard |
503 |
DETECTION_UNAVAILABLE |
We cannot watch that network right now. We refuse rather than take a payment we cannot see — retry shortly |
503 |
POOL_EXHAUSTED |
Every receiving address on that network is held by an open invoice — add addresses or raise your pool limit |
503 |
POOL_NOT_PROVISIONED |
You have no receiving address on that network yet |
402 |
PAYMENT_REQUIRED |
You are over your plan's capacity — top up or upgrade |
400 |
VALIDATION_ERROR |
A malformed body, or a network or asset that is unknown or switched off — the message names it |
Other refusals you may meet, each with its own code: CHAIN_DISABLED / ASSET_DISABLED 422 (we paused it), XRPL_XPUB_MISSING 400, XRPL_DEPOSIT_UNAVAILABLE 422, ADDRESS_NOT_WATCHED 503 (the address could not be registered for watching — retry), EMAIL_NOT_VERIFIED and ACCOUNT_NOT_ACTIVE 403.
Get an invoice#
/invoices/:id
Authorization: Bearer cv2_<mode>_<prefix>_<secret>Response 200:
{
"id": "550e8400-...",
"chainId": "11155111",
"assetType": "native",
"assetContract": null,
"assetDecimals": 18,
"assetSymbol": "ETH",
"receiveAddress": "0xabc...def",
"receiving_address": "0xabc...def",
"receiveTag": null,
"receiveType": "POOL",
"external_ref": "order_8823",
"expectedAmount": "10000000000000000",
"switchQuote": null,
"paidAmountTotal": "10000000000000000",
"amountStatus": "PAID",
"overpaidAmount": "0",
"status": "PAID_CONFIRMED",
"displayStatus": "paid",
"expiresAt": "2026-05-30T12:30:00.000Z",
"confirmedAt": "2026-05-30T12:12:00.000Z",
"finalConfirmations": 20,
"finalizedAt": null,
"createdAt": "2026-05-30T12:00:00.000Z",
"updatedAt": "2026-05-30T12:12:00.000Z",
"metadata": { "external_ref": "order_8823" },
"merchantId": "uuid",
"firstPaymentSeenAt": "2026-05-30T12:10:05.000Z",
"minConfirmations": 2,
"matchedTxHash": "0x...",
"paymentSummary": { "paymentCount": 1, "totalPaidFromEvents": "10000000000000000", "matches": true }
}paidAmountTotal is what has been credited so far, in base units. confirmedAt is when the invoice was paid; finalizedAt when it reached the ship-safe depth. metadata holds only the keys you sent.
donation is present on every invoice read and is null unless the invoice was created from a donation page: then it is { "donorName", "message", "usdCents", "amountMode", "presetUsed", "hiddenAt" } — the name and message the donor typed (null when left blank; the page shows "Anonymous"), the donation's value in US cents at the moment it was chosen (null when the coin could not be priced), whether the page priced in dollars (usd) or in the coin (asset), whether a preset was pressed, and hiddenAt — when you hid this donation from your public donation page and stream overlays in the dashboard (null while it is shown). A hidden donation still counts toward the page's goal.
List invoices#
/invoices?status=PAID_CONFIRMED&limit=20&offset=0
Authorization: Bearer cv2_<mode>_<prefix>_<secret>| Param | Default | Description |
|---|---|---|
status |
— | CREATED, PAID_UNCONFIRMED, PAID_CONFIRMED, PAID_LATE, EXPIRED or CLOSED |
chainId |
— | One network only |
customerId |
— | One customer's invoices only |
createdAfter |
— | ISO 8601 — created at or after this time |
createdBefore |
— | ISO 8601 — created before this time |
limit |
10 | 1 to 100 |
offset |
0 | For paging |
Only your current mode's invoices (Live or Test) are listed.
Response 200:
{
"invoices": [ /* invoices — the Get shape without minConfirmations, matchedTxHash and paymentSummary */ ],
"total": 42,
"metrics": { "total": 120, "byStatus": { "CREATED": 3, "PAID_UNCONFIRMED": 0, "PAID_CONFIRMED": 100, "PAID_LATE": 0, "EXPIRED": 17, "CLOSED": 0 }, "byAsset": [ /* … */ ] },
"limit": 10,
"offset": 0
}total counts the invoices matching your filters — use it to page. metrics summarises the same filters without status; it is null when the summary could not be computed, and the list is still served.
List an invoice's payments#
/invoices/:id/payments
Authorization: Bearer cv2_<mode>_<prefix>_<secret>Every on-chain transfer matched to the invoice, on every network — useful when a payer sent the amount in several transfers. On Bitcoin, paymentEventId is null and a transfer still in the mempool is listed with no blockNumber but not counted in summary. A transfer undone by a chain reorganisation stays listed but is not counted either. simulated is true for a simulated payment.
{
"invoiceId": "550e8400-...",
"expectedAmount": "10000000000000000",
"paidAmountTotal": "10000000000000000",
"paymentCount": 1,
"payments": [
{
"paymentEventId": "uuid",
"txHash": "0x...",
"amount": "10000000000000000",
"blockNumber": "19000000",
"confirmations": 12,
"minConfirmations": 2,
"state": "confirmed",
"stateLabel": "Confirmed",
"seenAt": "2026-05-30T12:10:05.000Z",
"createdAt": "2026-05-30T12:10:05.000Z",
"simulated": false
}
],
"summary": { "totalPaidFromEvents": "10000000000000000", "invoicePaidTotal": "10000000000000000", "matches": true }
}Cancel an invoice#
/invoices/:id/cancel
Authorization: Bearer cv2_<mode>_<prefix>_<secret>Only an open invoice that has seen no payment at all can be cancelled — not even a partial one, or a Bitcoin payment still waiting in the mempool. Cancelling expires the invoice exactly as its clock would: its address is released after the usual cooldown, and you receive invoice.expired.
Response 200: { "success": true }
| Status | code |
When |
|---|---|---|
409 |
INVOICE_PAYMENT_SEEN |
A payment has already been seen — one still confirming, or part of the amount. Leave the invoice open; refund from your own wallet if the payer asks |
400 |
INVOICE_NOT_OPEN |
The invoice is already paid, expired or closed |
404 |
NOT_FOUND |
No such invoice on your account |
Simulate a payment (Test mode)#
/invoices/:id/simulate-payment
Authorization: Bearer cv2_test_<prefix>_<secret>
Content-Type: application/jsonPays an open test-network invoice without sending coins, so you can watch your integration handle every step. The payment goes through the same steps a real one does, a few seconds apart, and each step sends its webhook:
- Detected —
invoice.receiving(the invoice isPAID_UNCONFIRMED, or staysCREATEDif the amount is short) - Confirmed —
invoice.paid,invoice.underpaidorinvoice.overpaid, once it reaches the invoice's confirmation depth - Final —
invoice.final
The body is optional. With no amount it pays exactly what is still owed; pass amount (base units, as a string) to test a short payment or an overpayment:
{ "amount": "500000" }Response 202:
{
"invoiceId": "550e8400-...",
"simulated": true,
"amount": "1000000",
"txHash": "sim_3f2a9c0d4b7e4e1a9b6c2d8e5f1a0b7c",
"stages": ["detect", "confirm", "finalize"]
}A simulated payment is marked everywhere: every webhook body carries "simulated": true, its txHash starts with sim_ (no block explorer has it), and the payment list and the payment page label it Simulated. Use a Test key — a Live key cannot see test invoices.
| Status | code |
When |
|---|---|---|
422 |
SIMULATION_TEST_NETWORK_ONLY |
The invoice is on a main network. Create it on a test network with a Test key |
422 |
ENVIRONMENT_MISMATCH |
A Live key was used on a test invoice |
422 |
SIMULATION_NOT_AVAILABLE |
A Bitcoin test invoice — not available yet. Use another test network, or send test bitcoin |
422 |
SIMULATION_AMOUNT_REQUIRED |
The invoice is already covered; pass an amount to simulate an overpayment |
409 |
INVOICE_NOT_OPEN |
The invoice is already paid, expired or closed |
409 |
INVOICE_HAS_REAL_PAYMENT |
The invoice has already received a real payment. Create a new invoice to simulate on |
400 |
INVALID_INPUT |
amount is not a positive whole number of base units |
404 |
NOT_FOUND |
No such invoice on your account |
Change an invoice's network or coin#
/invoices/:id
Authorization: Bearer cv2_<mode>_<prefix>_<secret>
Content-Type: application/json{
"chainId": "137",
"assetType": "erc20",
"assetContract": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
}| Field | Required | Notes |
|---|---|---|
chainId |
Yes | An EVM, Tron or XRP Ledger network you accept payment on. Bitcoin cannot be switched to or from |
assetType |
Yes | "native" or "erc20" (defaults to "erc20") |
assetContract |
For a token | The token's contract address; on Tron its 0x hex form |
How the amount changes. The same coin on another network (USDC on Ethereum → USDC on Base) keeps its value, re-expressed in the new asset's base units and rounded up. A different coin (USDC → ETH) is converted through USD at two price quotes each at most 60 seconds old, always from the invoice's original amount and coin (an earlier switch is never compounded), rounded up. After any switch the invoice carries switchQuote — { method, quote, at }, where method is unchanged, rescaled or converted, and quote is { sourceSymbol, sourceUsd, targetSymbol, targetUsd, quotedAt } for a conversion (null otherwise) — so the figure can be checked. It is null on an invoice never switched. Read the new expectedAmount from the response.
What can be switched. An invoice in CREATED status that has seen no payment, paid to a receiving address or to an XRP Ledger destination tag. Switching to the XRP Ledger follows your XRP deposit mode, as creating an invoice there does: in tag mode the invoice gets a new destination tag (receiveTag); in address mode, its own address. Switching away from a tagged invoice retires its tag: a payment that still arrives with the old tag is not credited to this or any other invoice; it is held for review — contact support with the transaction hash. The new address is locked before the old one is released.
| Status | code |
When |
|---|---|---|
400 |
INVOICE_NOT_SWITCHABLE |
The invoice is not CREATED — it is paid, expired or closed, or it is a Bitcoin invoice |
400 |
FAMILY_NOT_SWITCHABLE |
The target is a Bitcoin network |
409 |
INVOICE_PAYMENT_SEEN |
A payment has already been seen — the payer must pay the rest in the same coin on the same network |
422 |
SWITCH_NOT_PRICEABLE |
A different coin, and no fresh price quote right now — try again in a moment |
422 |
ENVIRONMENT_MISMATCH |
The target network is on the other side of your Live/Test mode |
503 |
POOL_EXHAUSTED |
Every receiving address on the target network is held by an open invoice, and your pool is at its limit |
503 |
ADDRESS_NOT_WATCHED |
The new address could not be registered for watching — retry |
402 |
CAPACITY_EXCEEDED |
Over your plan's capacity |
400 |
VALIDATION_ERROR |
No address could be assigned (a lock was lost to a concurrent request, or on the XRP Ledger the invoice is too small to activate a new account), or the asset is not found on that network. The message says which |
The target must also pass every rule a new invoice there would (ASSET_NOT_ACCEPTED, CHAIN_NOT_IN_PLAN, DETECTION_UNAVAILABLE, POOL_NOT_PROVISIONED… with the same statuses as on create).
Response 200: the updated invoice, in the Get shape without firstPaymentSeenAt, minConfirmations, matchedTxHash and paymentSummary.
Public invoice view#
These need no key — the invoice id is the only credential, so share it with the paying customer only. The hosted payment page for an invoice is https://app.unheld.io/invoice/<id>.
GET /invoices/public/:id # the payment page's data
GET /invoices/:id/status # the smallest view
POST /invoices/public/:id/cancel # the payer cancels an unpaid invoice/status returns id, status, expectedAmount, chainId, receiveAddress, receiving_address, receiveTag, assetContract and expiresAt. The payer's cancel follows the same rule as yours: no payment seen.