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:
- 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
owneris by construction that identity's own. No lookup, and every permission. - It is another authority on that identity's ledger stream — typically an
offledgerkey added withaddKey. The server reads that identity's keys off the ledger and grants exactly thescopesthe key carries;invoices:createis 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
| Field | Type | Required | Notes |
|---|---|---|---|
owner | string | yes | The identity stream id. Names the identity the request acts as, and the signing key is checked against it — see above. |
walletIds | string[] | yes | At least one. Every wallet must already belong to owner. |
name | string | yes | Trimmed, truncated to 80 characters. |
amount | string | yes | A decimal string. Must parse as a number greater than zero. |
currency | string | no | native (default) or a token contract address that one of the wallets' networks credits. Ignored when currencies is sent. |
currencies | string[] | no | Several 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. |
description | string | no | Trimmed, truncated to 500 characters. Empty becomes null. |
reference | string | no | Your own id, truncated to 80 characters. Unique across all invoices. Required for webhooks. |
callbackUrl | string | no | Per-invoice webhook override. Must be an https: URL on a public host — see Webhooks. |
expiresAt | number | no | Unix milliseconds. When an unpaid invoice stops taking payments: in the future and at most 30 days away. Omitted = 30 days from creation. |
newWallets | boolean | no | true 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 with400. (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
| Status | Meaning |
|---|---|
400 | Missing owner/walletIds/name, or a non-positive amount |
400 | Unsupported currency. |
400 | Only USD stablecoins can be combined in one invoice. — a native coin or other token was listed with another currency |
400 | An invoice in a native coin or a non-stablecoin token accepts wallets on that coin's network only. |
400 | A wallet on <network> can't receive any of this invoice's stablecoins. |
400 | Every wallet must belong to this identity. — see the timing note below |
400 | newWallets: <ids> already took another invoice — a wallet sent as new already belongs to an invoice |
400 | callbackUrl is not a valid https: URL, or names localhost or a private address — the message says which |
401 | Signature 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 |
403 | code: "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 |
409 | This reference id is already in use by another invoice. |
409 | An unpaid invoice for this exact amount already exists on this wallet |
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.
| Status | Meaning |
|---|---|
200 | {"id": "...", "status": "cancelled"} |
400 | The body doesn't name the invoice in the URL |
401 | Not signed by the invoice owner's own key |
404 | No such invoice |
409 | Already paid, expired or cancelled (the body's status says which) |
List an identity's invoices
GET /api/users/:id/invoices
Newest first.
Invoice fields
| Field | Type | Notes |
|---|---|---|
id | string | 32 hex characters, no dashes |
owner | string | Identity stream id |
wallet_id | string | First wallet. Kept for rows created before multi-wallet support |
wallet_ids | string | null | Comma-joined list. Authoritative when present |
name | string | |
description | string | null | |
reference | string | null | Your id. Unique |
amount | string | Decimal string |
currency | string | The first accepted currency: native or a token contract address |
currencies | string[] | Every accepted currency (list route) |
callback_url | string | null | Per-invoice webhook override |
created_at | number | Unix milliseconds |
expires_at | number | Unix milliseconds an unpaid invoice stops taking payments |
cancelled_at | number | null | Unix milliseconds the owner cancelled it |
status | string | pending, 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
kindofconfirmed(apendingsighting 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 currency | Matches invoice currency |
|---|---|
Tether | USDT |
#native | native |
null / empty | native |
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.
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.