Skip to main content

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:

  1. The deposit's destination address is a known wallet.
  2. An invoice on that wallet has a reference set. An invoice without one is skipped entirely — this is the most common reason a webhook never arrives.
  3. The deposit's currency matches the invoice's (with the Tether / #native normalisation described in Invoices).
  4. 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.

The secret survives URL changes

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
}
FieldTypeNotes
referencestringYour own reference from the invoice — the field to key off
status"pending" | "confirmed"See below
invoiceIdstringVarnir's invoice id
networkstringSEP or TRX-NILE
txHashstringThe L1 transaction hash
toAddressstring | nullThe receiving wallet address
amountstring | nullRaw on-chain units for a token — not a decimal amount
currencystring | nullThe raw deposit label: Tether, #native, or null
receivedAtnumberUnix milliseconds
amount and currency are raw, not normalised

These 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.

One secret, all your invoices

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.