DocsStart free
English
API docsInvoices

API docsPayments

Invoices

Scope invoices:write to create, switch and cancel; invoices:read to list and read.

Create an invoice#

POSTHTTP
/invoices
Authorization: Bearer cv2_<mode>_<prefix>_<secret>
Content-Type: application/json
Idempotency-Key: order_8823

Idempotency-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:

JSON
{
  "chainId": "1",
  "assetType": "erc20",
  "assetContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
  "amountDecimal": "100",
  "expiresInSeconds": 900,
  "metadata": { "external_ref": "order_8824" }
}

Response 201:

JSON
{
  "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#

GETHTTP
/invoices/:id
Authorization: Bearer cv2_<mode>_<prefix>_<secret>

Response 200:

JSON
{
  "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#

GETHTTP
/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:

JSON
{
  "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#

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

JSON
{
  "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#

POSTHTTP
/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)#

POSTHTTP
/invoices/:id/simulate-payment
Authorization: Bearer cv2_test_<prefix>_<secret>
Content-Type: application/json

Pays 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:

  1. Detected — invoice.receiving (the invoice is PAID_UNCONFIRMED, or stays CREATED if the amount is short)
  2. Confirmed — invoice.paid, invoice.underpaid or invoice.overpaid, once it reaches the invoice's confirmation depth
  3. 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:

JSON
{ "amount": "500000" }

Response 202:

JSON
{
  "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#

PATCHHTTP
/invoices/:id
Authorization: Bearer cv2_<mode>_<prefix>_<secret>
Content-Type: application/json
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>.

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