Skip to main content

Invoices

Why this matters​

Invoices let a business ask for a specific payment and know when it arrives, without watching the chain itself. You name the amount and the wallets it can be paid into, share a hosted payment page, and Varnir marks the invoice paid from the deposits it sees, with a signed webhook to your server if you want one. This section is the HTTP reference behind the SDK's createInvoice, the webhook settings, and the scanner API's rate limits.

How it works​

An invoice is a request for a specific amount, in a specific currency, payable to one or more of your own wallets. It is bookkeeping, not a ledger transaction: creating one is a database write, and "paid" is computed from observed deposits rather than stored.

Base URL for everything on this page: https://scanner.varnir.site

Create an invoice​

POST /api/invoices

Every request must be signed. Send the X-Varnir-* headers from buildApiKeyAuthHeaders with route tag POST /api/invoices, and put the identity stream id in the body's owner field. There is no unsigned path: a request without the three headers is a 401, whatever the body says.

owner is not decoration and it is not ignored. It names the identity the request is claiming to act as, and the server checks the signing key against that identity two ways:

  1. It is the identity's own onboarding key. The stream id is a hash of that key's public half, so a signature from a key hashing to owner is by construction that identity's own. No lookup, and every permission.
  2. It is another authority on that identity's ledger stream — typically an offledger key added with addKey. The server reads that identity's keys off the ledger and grants exactly the scopes the key carries; invoices:create is the one this route needs.

Both paths need owner, because without an identity named there is no stream to look the signing key up on. Omit it and the request is refused — there is no third path.

Request body​

FieldTypeRequiredNotes
ownerstringyesThe identity stream id. Names the identity the request acts as, and the signing key is checked against it — see above.
walletIdsstring[]yesAt least one. Every wallet must already belong to owner.
namestringyesTrimmed, truncated to 80 characters.
amountstringyesA decimal string. Must parse as a number greater than zero.
currencystringnonative (default) or a token contract address that one of the wallets' networks credits. Ignored when currencies is sent.
currenciesstring[]noSeveral accepted currencies, each payable as the full amount of that token (no price feed). More than one only when every one is a USD stablecoin; the first is also stored as currency. Trimmed, de-duplicated, at most 10. Wins over currency.
descriptionstringnoTrimmed, truncated to 500 characters. Empty becomes null.
referencestringnoYour own id, truncated to 80 characters. Unique across all invoices. Required for webhooks.
callbackUrlstringnoPer-invoice webhook override. Must be an https: URL on a public host — see Webhooks.
expiresAtnumbernoUnix milliseconds. When an unpaid invoice stops taking payments: in the future and at most 30 days away. Omitted = 30 days from creation.
newWalletsbooleannotrue when the walletIds were created for this invoice only. They are recorded as invoice wallets (the explorer's user page can filter by them), and the request is refused if any invoice already uses one of them. The SDKs' createInvoice with new wallets sends it for you.

Response​

{"id": "a1b2c3...", "url": "/pay/a1b2c3..."}

url is relative — the hosted payment page is https://scanner.varnir.site/pay/<id>. The response also carries currencies, the accepted list as stored.

Currency and network rules​

  • A native coin (native) or a token that is not a USD stablecoin is the invoice's only currency, and every wallet must be on one network — that coin's. Wallets on two networks are refused with 400. (Before multi-currency invoices, a native invoice could list wallets on several networks; it can't any more.)
  • Several USD stablecoins may be combined, and may span networks, but each wallet must be on a network that credits at least one of them.
  • A payment pays the invoice only in one accepted currency, for the exact remaining amount. Payments in two different stablecoins are never added together.
  • What counts as a USD stablecoin is an explicit list of contracts, not a ticker; a token merely named "USDT" does not qualify.

Errors​

StatusMeaning
400Missing owner/walletIds/name, or a non-positive amount
400Unsupported currency.
400Only USD stablecoins can be combined in one invoice. — a native coin or other token was listed with another currency
400An invoice in a native coin or a non-stablecoin token accepts wallets on that coin's network only.
400A wallet on <network> can't receive any of this invoice's stablecoins.
400Every wallet must belong to this identity. — see the timing note below
400newWallets: <ids> already took another invoice — a wallet sent as new already belongs to an invoice
400callbackUrl is not a valid https: URL, or names localhost or a private address — the message says which
401Signature check failed — missing headers, a bad signature, more than five minutes of clock skew, a key with no invoices:create scope, a lapsed key, or no owner at all. See Keys
403code: "plan_limit": the identity already has as many unpaid invoices as its plan allows (Free 3, Lite 5, Hobbyist 30, Trader unlimited). The body carries limit, planId, count and cap; an invoice stops counting once it is paid. stale: true means the plan couldn't be read and Free's cap stood in — retry shortly
409This reference id is already in use by another invoice.
409An unpaid invoice for this exact amount already exists on this wallet
Ownership is checked against a lagging store

The wallet-ownership check reads Varnir's database, which is populated asynchronously after an on-chain assignment. Creating an invoice in the seconds right after AssignOwner commits can still fail with Every wallet must belong to this identity. Poll GET /api/users/:id/wallets — the same source this check reads — before invoicing a freshly assigned wallet.

The amount-collision 409​

A new invoice is rejected if a still-unpaid invoice already exists for the exact same amount, in the same currency, on any of the same wallets:

An unpaid invoice for this exact amount (10 native) already exists on this
wallet - use a different amount, or wait for it to be paid.

This is deliberate, not a limitation to work around. Payment detection is amount-based, so two open invoices for the same amount on the same wallet are genuinely ambiguous — an incoming payment could settle either one, and nothing on-chain says which. Once the first is paid, the amount is free again.

Invoices on different wallets never collide, because a payment into a wallet only ever matches that wallet's invoices. Giving each invoice its own new wallet (the SDK's createInvoice({newWallets}), or New wallet for this invoice on the wallet's Invoices page) is how you raise any number of open invoices for the same amount. New wallets are ordinary wallets: they stay yours, count towards your plan's wallet limit, and cost credits like any wallet added after sign-up (How credits work).

Amounts compare by value, not string: 100 and 100.00 collide.

Expiry and cancel​

An unpaid invoice is pending until it is paid, it expires (expiresAt, 30 days after creation at most), or its owner cancels it. Only a pending invoice takes payments, collides with a new invoice's amount, or counts toward the plan's pending-invoice cap.

A payment that arrives for an expired or cancelled invoice is not lost: it is credited to the owner's balance like any other deposit or transfer. It just isn't matched to that invoice, which stays expired or cancelled.

POST /api/invoices/:id/cancel

Body {"id": "<the same id>"}, signed with the identity's own key (route tag POST /api/invoices/cancel; a delegated key cannot cancel). The id is in the signed body so a signature for one invoice can't cancel another.

StatusMeaning
200{"id": "...", "status": "cancelled"}
400The body doesn't name the invoice in the URL
401Not signed by the invoice owner's own key
404No such invoice
409Already paid, expired or cancelled (the body's status says which)

List an identity's invoices​

GET /api/users/:id/invoices

Newest first.

Invoice fields​

FieldTypeNotes
idstring32 hex characters, no dashes
ownerstringIdentity stream id
wallet_idstringFirst wallet. Kept for rows created before multi-wallet support
wallet_idsstring | nullComma-joined list. Authoritative when present
namestring
descriptionstring | null
referencestring | nullYour id. Unique
amountstringDecimal string
currencystringThe first accepted currency: native or a token contract address
currenciesstring[]Every accepted currency (list route)
callback_urlstring | nullPer-invoice webhook override
created_atnumberUnix milliseconds
expires_atnumberUnix milliseconds an unpaid invoice stops taking payments
cancelled_atnumber | nullUnix milliseconds the owner cancelled it
statusstringpending, paid, expired or cancelled (list route only)

To read an invoice's wallets, use wallet_ids when it is non-empty and fall back to [wallet_id] otherwise.

paid and status are worked out when the list is read; neither is stored. See below.

How "paid" is computed​

Paid status is derived on read, from observed deposits. An invoice is paid when confirmed deposits satisfying all of these sum to at least amount:

  • to any of the invoice's wallet addresses,
  • matching the invoice's currency,
  • with kind of confirmed (a pending sighting does not count),
  • received strictly after created_at.

That last condition is easy to trip over: a deposit that arrived before the invoice existed never counts toward it. Create the invoice first, then ask for payment.

Sums are exact fixed-point arithmetic on BigInt scaled to 18 places, not floating point — an invoice covered by many partial deposits does not drift.

Currency matching​

Deposit records and invoices spell currencies differently, and matching normalises both sides:

Deposit currencyMatches invoice currency
TetherUSDT
#nativenative
null / emptynative

If you compare a raw deposit's currency against an invoice's yourself, you must handle these aliases — a Tron USDT deposit is labelled Tether, and a native transfer may be labelled #native or nothing at all. Getting this wrong makes a genuinely paid invoice show as unpaid forever.

Known gap: native-currency amounts

Token deposit amounts are raw on-chain integers and get their decimal point placed before comparison. Native-currency amounts are used as-is, because the raw-versus-decimal format is not consistent across the L1 providers Varnir reads from. Native-currency payment detection is therefore less reliable than USDT today. This is a known gap, not something to design around.

Paying an invoice on the ledger​

A payment does not have to arrive over L1. send() (or transferOnLedger) to one of an invoice's wallet addresses settles instantly on the ledger when that address resolves to a Varnir wallet Varnir can still confirm the owner of — see Paying: send() and where it settles — and pays the invoice the same way an L1 deposit does.

The webhook delivery differs from an L1 deposit's two-stage "pending" → "confirmed" (see Webhooks — two deliveries per deposit): an on-ledger payment settles atomically, so it fires once, directly at "confirmed", with txHash set to the ledger transaction's umid rather than an L1 transaction hash.