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
All three require a signed request — the same X-Varnir-Api-Key /
X-Varnir-Timestamp / X-Varnir-Signature headers described in
The signing model, signed with either
your own onboarding key or another key on your identity holding the
webhooks:manage scope. The route tag to sign is the
literal GET /api/users/:id/webhook (or PUT/DELETE), with :id left as
written rather than substituted, and an empty body string for GET and
DELETE. The SDK does this for you.
Unlike the other /api/users/:id/* reads, knowing the identity id is not
sufficient here: GET returns the signing secret, and anyone holding it can
forge notifications to your callback.
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 with a 400 a callbackUrl that is not an https: URL, or
that names localhost or a loopback, private or link-local IP address — Varnir's
server could not reach those, and a delivery over plain http: could be read and
replayed in transit. The same rule applies to an invoice's own callbackUrl.
A URL saved before https was required is kept, but deliveries to it are
skipped (and logged on Varnir's side) until you save an https: one. A skipped
per-invoice URL does not fall back to your global default.
The delivery
POST <your callback URL>
Content-Type: application/json
X-Varnir-Timestamp: <unix seconds when it was signed>
X-Varnir-Signature: v1=<lowercase hex HMAC-SHA256 of "<timestamp>.<raw body>">
Body:
{
"reference": "your-invoice-reference",
"status": "confirmed",
"invoiceId": "a1b2c3…",
"network": "TRX-NILE",
"txHash": "…",
"toAddress": "…",
"amount": "10000000",
"currency": "9b28202c90af764ffa36279857a6d7cd928bb06c",
"symbol": "USDT",
"decimals": 6,
"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, TRX-NILE, tBNB, base-sepolia, arb-sepolia or polygon-amoy |
txHash | string | The L1 transaction hash |
toAddress | string | null | The receiving wallet address |
amount | string | null | Raw on-chain units — not a decimal amount |
currency | string | null | The token's L1 contract address, #native, or null |
symbol | string | null | Display ticker from the token registry, e.g. USDT |
decimals | number | null | Scale for amount, from the registry |
receivedAt | number | Unix milliseconds |
amount is raw, and currency is an addressamount is in the token's own integer units, so do not compare it against your
invoice's decimal amount without scaling it by decimals first.
currency identifies the token by its L1 contract address, not by a ticker —
match on it, and display symbol. A ticker was never an identity: "USDT" named
one contract on Sepolia and a different one on Nile, and two tokens on one
network may legitimately share a symbol.
symbol and decimals come from the on-ledger token registry and are null
if it does not describe the token. amount is exact either way.
currency used to carry a ticker, spelled Tether for Tron USDT. Deposits
recorded before the change still carry those older labels; anything new is an
address.
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". A "pending" delivery is a sighting, not a payment: the
transaction can still be replaced, dropped, reorganised away or reverted, and
then no "confirmed" delivery ever follows for it.
On Bitcoin Signet the "pending" delivery now arrives while the payment is
still in the mempool, with no confirmations at all. A transaction at that stage
can still be replaced by a higher-fee one or dropped, so it may never be
followed by a "confirmed" delivery for the same txHash. Varnir never credits
an unconfirmed Bitcoin output: the balance moves, and the invoice is paid, only
at "confirmed" (three confirmations on Signet).
On the EVM networks the "pending" delivery arrives as soon as the payment is
in a block, before the network's deposit depth (see
Networks). A block that shallow can still be reorganised away, so
the same rule holds: act irreversibly only on "confirmed".
Swap settlement notifications
A second kind of delivery fires when a swap settles on the exchange — an
atomic co-signed swap, a resting-order fill, or a matcher pairing two orders
(see the exchange guide and Swaps). Both
parties to the trade are notified, each at their own global default callback
URL (set with PUT /api/users/:id/webhook, above). A per-invoice
callbackUrl does not apply — a swap has no invoice — and an identity with no
default callback URL configured is simply not notified.
POST <your callback URL>
Content-Type: application/json
X-Varnir-Timestamp: <unix seconds when it was signed>
X-Varnir-Signature: v1=<lowercase hex HMAC-SHA256 of "<timestamp>.<raw body>">
Body:
{
"type": "swap-settled",
"status": "settled",
"umid": "…",
"role": "maker",
"counterparty": "…identity stream id…",
"gave": {"network": "TRX-NILE", "token": "#native", "amount": "1000000"},
"received": {"network": "SEP", "token": "#native", "amount": "10000000000000"},
"settledAt": 1730000000000
}
| Field | Type | Notes |
|---|---|---|
type | "swap-settled" | The discriminator. A deposit delivery (above) has no type field — key off this to tell the two apart on one URL |
status | "settled" | A swap settles atomically; there is no pending stage as there is for a deposit |
umid | string | The single ledger transaction that settled both legs |
role | "maker" | "taker" | This identity's side of the trade |
counterparty | string | The other party's identity stream id |
gave | object | What this identity gave — network, token (L1 contract address, or #native), and amount in raw smallest units |
received | object | What this identity received, same shape |
settledAt | number | Unix milliseconds |
It is signed and verified exactly like a deposit delivery — the same secret,
the same X-Varnir-Timestamp / X-Varnir-Signature pair — and carries the same
delivery guarantees (none; see below).
Verifying the signature
Every delivery carries two headers:
X-Varnir-Timestamp— when Varnir signed it, in unix seconds, as a decimal string.X-Varnir-Signature—v1=followed by the lowercase hex HMAC-SHA256, keyed with your webhook secret (its UTF-8 text, as shown), of the timestamp, a literal., and the raw request body:HMAC-SHA256(secret, "<timestamp>.<raw body>").
To verify:
- Refuse the request if the timestamp is more than 5 minutes either side of your own clock. This is what stops a captured delivery being replayed later; the signature stops the timestamp being changed.
- Compute the HMAC over the bytes you received, before any JSON parsing — re-serialising the parsed object is not guaranteed to reproduce the same string.
- Compare it with the value after
v1=in constant time. Reject anything without thev1=prefix.
import {createHmac, timingSafeEqual} from 'node:crypto';
function verify(
rawBody: string,
timestampHeader: string | undefined,
signatureHeader: string | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
if (!timestampHeader || !/^\d+$/.test(timestampHeader)) return false;
if (Math.abs(nowSeconds - Number(timestampHeader)) > 300) return false;
if (!signatureHeader?.startsWith('v1=')) return false;
const expected = createHmac('sha256', secret).update(`${timestampHeader}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signatureHeader.slice(3).toLowerCase(), 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
The same, on the Web Crypto API (Cloudflare Workers, Deno, browsers), which
has no timingSafeEqual — so it asks crypto.subtle.verify to do the
comparison instead:
async function verify(
rawBody: string,
timestampHeader: string | null,
signatureHeader: string | null,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<boolean> {
if (!timestampHeader || !/^\d+$/.test(timestampHeader)) return false;
if (Math.abs(nowSeconds - Number(timestampHeader)) > 300) return false;
const hex = signatureHeader?.startsWith('v1=') ? signatureHeader.slice(3) : '';
if (!/^[0-9a-fA-F]{64}$/.test(hex)) return false;
const received = new Uint8Array(hex.match(/../g)!.map((byte) => parseInt(byte, 16)));
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{name: 'HMAC', hash: 'SHA-256'},
false,
['verify'],
);
return crypto.subtle.verify('HMAC', key, received, new TextEncoder().encode(`${timestampHeader}.${rawBody}`));
}
Reject any request that does not verify — the callback URL is not a secret,
and the signature is the only thing proving a delivery came from Varnir. Even a
verified delivery can arrive twice inside the 5-minute window, so keep
deduplicating on txHash + status (or umid for a swap).
Every Varnir SDK ships this check, so you don't have to write it. Each takes the secret, the raw body and the headers (or the two header values) and returns a boolean, never throws, and allows 300 seconds of clock difference by default:
| SDK | Verify from headers | Verify from values |
|---|---|---|
TypeScript (@varnir/chain-client) | verifyWebhookRequest(secret, rawBody, headers) | verifyWebhookSignature({secret, body, timestamp, signature}) |
| Python | verify_webhook_request(secret, raw_body, headers) | verify_webhook_signature(secret, body, timestamp, signature) |
| Kotlin / Java | Webhooks.verifyWebhookRequest(...) | Webhooks.verifyWebhookSignature(...) |
| .NET | Webhooks.VerifyRequest(...) | Webhooks.VerifySignature(...) |
| Go | VerifyWebhookRequest(secret, rawBody, header) | VerifyWebhookSignature(secret, body, timestamp, signature) |
| PHP | Webhooks::verifyRequest(...) | Webhooks::verifySignature(...) |
| Rust | verify_webhook_request(...) | verify_webhook_signature(...) |
| Dart | verifyWebhookRequest(secret, rawBody, headers) | verifyWebhookSignature(secret, body, timestamp, signature) |
| Swift | Webhooks.verifyRequest(secret:body:headers:) | Webhooks.verifySignature(secret:body:timestamp:signature:) |
Each language page has the exact signature and an example.
A fixed vector to check your implementation against:
| secret | 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef |
X-Varnir-Timestamp | 1767225600 |
| raw body | {"reference":"inv-42","status":"confirmed"} |
X-Varnir-Signature | v1=86658a7faa6df3f35d6d8fcaed63a80e74f10e656111526d859d279e1fb85d01 |
Deliveries used to carry a bare hex HMAC of the body alone and no timestamp. Code written against that fails every delivery now — it should: update it to the scheme above.
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. Plan around that.
- 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 names are reused for outbound requests you send to Varnir, where
X-Varnir-Signature means something different — a base64 DER ECDSA signature
over routeTag\ntimestamp\nbody, not a v1= hex HMAC — and
X-Varnir-Timestamp there is unix milliseconds, not seconds.
See The signing model.