Webhooks
When a deposit lands on one of your wallets and matches an invoice, Varnir POSTs a signed JSON payload to your callback URL.
Prerequisites
A delivery only fires when all of these are true:
- The deposit's destination address is a known wallet.
- An invoice on that wallet has a
referenceset. An invoice without one is skipped entirely — this is the most common reason a webhook never arrives. - The deposit's currency matches the invoice's (with the
Tether/#nativenormalisation described in Invoices). - A callback URL is resolvable: the invoice's own
callback_url, or the owner's global default. The per-invoice one wins.
Configuring the default callback URL
GET /api/users/:id/webhook
PUT /api/users/:id/webhook {"callbackUrl": "https://example.com/hook"}
DELETE /api/users/:id/webhook
GET returns:
{
"owner": "…",
"callback_url": "https://example.com/hook",
"secret": "…64 hex characters…",
"created_at": 1730000000000,
"updated_at": 1730000000000
}
If nothing is configured, callback_url and secret come back null.
The secret is returned on every GET, not once at creation — you need it
indefinitely to keep verifying signatures.
A fresh secret is generated only the first time. Changing your callback URL later keeps the existing secret, so your verification code does not have to change every time the URL does.
You also do not need to call PUT at all if you only ever use per-invoice
callbackUrl overrides — a secret is generated on demand the first time a
delivery is made for you.
PUT rejects a callbackUrl that is not a valid http: or https: URL with
a 400.
The delivery
POST <your callback URL>
Content-Type: application/json
X-Varnir-Signature: <lowercase hex HMAC-SHA256 of the raw body>
Body:
{
"reference": "your-invoice-reference",
"status": "confirmed",
"invoiceId": "a1b2c3…",
"network": "TRX-NILE",
"txHash": "…",
"toAddress": "…",
"amount": "10000000",
"currency": "Tether",
"receivedAt": 1730000000000
}
| Field | Type | Notes |
|---|---|---|
reference | string | Your own reference from the invoice — the field to key off |
status | "pending" | "confirmed" | See below |
invoiceId | string | Varnir's invoice id |
network | string | SEP or TRX-NILE |
txHash | string | The L1 transaction hash |
toAddress | string | null | The receiving wallet address |
amount | string | null | Raw on-chain units for a token — not a decimal amount |
currency | string | null | The raw deposit label: Tether, #native, or null |
receivedAt | number | Unix milliseconds |
amount and currency are raw, not normalisedThese are passed straight through from the deposit record. A USDT amount is in
the token's own integer units, and the currency is spelled Tether, not
USDT. Do not compare amount directly against your invoice's decimal amount
without scaling it first.
Two deliveries per deposit
The same deposit fires twice: once at "pending" when it is first seen,
once at "confirmed". This lets you show "payment detected" and "payment
confirmed" as separate states.
Treat txHash + status as the deduplication key, and only act irreversibly on
"confirmed".
Verifying the signature
X-Varnir-Signature is the lowercase hex HMAC-SHA256 of the raw request
body, keyed with your webhook secret. Compute it over the bytes you received,
before any JSON parsing — re-serialising the parsed object is not guaranteed to
reproduce the same string.
import {createHmac, timingSafeEqual} from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(header, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
The same, on the Web Crypto API (Cloudflare Workers, Deno, browsers):
async function verify(rawBody: string, header: string, secret: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{name: 'HMAC', hash: 'SHA-256'},
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody));
const expected = Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, '0')).join('');
return expected === header.toLowerCase();
}
Compare in constant time where your runtime offers it. Reject any request whose signature does not verify — the callback URL is not a secret, and the signature is the only thing proving a delivery came from Varnir.
Every delivery is signed with the owner's single secret, regardless of which invoice it is for or which URL it went to. Your verification code never needs to vary per invoice.
Delivery guarantees
There are effectively none, and this is worth planning around.
- No retries. A delivery is attempted once. A failure — non-2xx, timeout, DNS error — is logged on Varnir's side and dropped. There is no durable queue in this app yet.
- 8 second timeout. Respond fast. Acknowledge first, do your work after.
- A non-2xx response is not a retry request. It just gets logged.
For anything you actually care about, treat webhooks as an optimisation and
reconcile by polling GET /api/users/:id/invoices as the source of truth. This
is testnet-grade delivery; a production integration would want a queue behind
it.
Reference: the other X-Varnir-Signature
The header name is reused for outbound requests you send to Varnir, where it means something different — a base64 DER ECDSA signature, not a hex HMAC. See The signing model.