Invoices
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
Two ways to authenticate:
- Signed with an API key — send the
X-Varnir-*headers frombuildApiKeyAuthHeaderswith route tagPOST /api/invoicesand theinvoices:createpermission.owneris taken from the verified key record, and anyownerin the body is ignored. - From the web wallet's own UI — no
X-Varnir-*headers, andowneris read from the body. This is an informal trust model that works because the wallet id is only known to whoever is logged into that identity. External integrations should use the signed path.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
owner | string | only on the unsigned path | The identity stream id. Ignored when API-key headers are present. |
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 USDT. Anything else is rejected. |
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 a valid http:/https: URL. |
Response
{"id": "a1b2c3...", "url": "/pay/a1b2c3..."}
url is relative — the hosted payment page is
https://scanner.varnir.site/pay/<id>.
Errors
| Status | Meaning |
|---|---|
400 | Missing owner/walletIds/name, or a non-positive amount |
400 | Unsupported currency. |
400 | Every wallet must belong to this identity. — see the timing note below |
400 | callbackUrl must be a valid http(s) URL. |
401 | API key auth failed (see API keys for the specific messages) |
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.
Amounts compare by value, not string: 100 and 100.00 collide.
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 | native or USDT |
callback_url | string | null | Per-invoice webhook override |
created_at | number | Unix milliseconds |
To read an invoice's wallets, use wallet_ids when it is non-empty and fall
back to [wallet_id] otherwise.
There is no paid field, and no status column. 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.