Skip to main content

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:

  1. Signed with an API key — send the X-Varnir-* headers from buildApiKeyAuthHeaders with route tag POST /api/invoices and the invoices:create permission. owner is taken from the verified key record, and any owner in the body is ignored.
  2. From the web wallet's own UI — no X-Varnir-* headers, and owner is 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

FieldTypeRequiredNotes
ownerstringonly on the unsigned pathThe identity stream id. Ignored when API-key headers are present.
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 USDT. Anything else is rejected.
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 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

StatusMeaning
400Missing owner/walletIds/name, or a non-positive amount
400Unsupported currency.
400Every wallet must belong to this identity. — see the timing note below
400callbackUrl must be a valid http(s) URL.
401API key auth failed (see API keys for the specific messages)
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.

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

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
currencystringnative or USDT
callback_urlstring | nullPer-invoice webhook override
created_atnumberUnix 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 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.