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

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.

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 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
}
FieldTypeNotes
referencestringYour own reference from the invoice — the field to key off
status"pending" | "confirmed"See below
invoiceIdstringVarnir's invoice id
networkstringSEP, TRX-NILE, tBNB, base-sepolia, arb-sepolia or polygon-amoy
txHashstringThe L1 transaction hash
toAddressstring | nullThe receiving wallet address
amountstring | nullRaw on-chain units — not a decimal amount
currencystring | nullThe token's L1 contract address, #native, or null
symbolstring | nullDisplay ticker from the token registry, e.g. USDT
decimalsnumber | nullScale for amount, from the registry
receivedAtnumberUnix milliseconds
amount is raw, and currency is an address

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

If you integrated before tokens were address-keyed

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
}
FieldTypeNotes
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
umidstringThe single ledger transaction that settled both legs
role"maker" | "taker"This identity's side of the trade
counterpartystringThe other party's identity stream id
gaveobjectWhat this identity gave — network, token (L1 contract address, or #native), and amount in raw smallest units
receivedobjectWhat this identity received, same shape
settledAtnumberUnix 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:

  1. 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.
  2. 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.
  3. Compare it with the value after v1= in constant time. Reject anything without the v1= 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:

SDKVerify from headersVerify from values
TypeScript (@varnir/chain-client)verifyWebhookRequest(secret, rawBody, headers)verifyWebhookSignature({secret, body, timestamp, signature})
Pythonverify_webhook_request(secret, raw_body, headers)verify_webhook_signature(secret, body, timestamp, signature)
Kotlin / JavaWebhooks.verifyWebhookRequest(...)Webhooks.verifyWebhookSignature(...)
.NETWebhooks.VerifyRequest(...)Webhooks.VerifySignature(...)
GoVerifyWebhookRequest(secret, rawBody, header)VerifyWebhookSignature(secret, body, timestamp, signature)
PHPWebhooks::verifyRequest(...)Webhooks::verifySignature(...)
Rustverify_webhook_request(...)verify_webhook_signature(...)
DartverifyWebhookRequest(secret, rawBody, headers)verifyWebhookSignature(secret, body, timestamp, signature)
SwiftWebhooks.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:

secret0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
X-Varnir-Timestamp1767225600
raw body{"reference":"inv-42","status":"confirmed"}
X-Varnir-Signaturev1=86658a7faa6df3f35d6d8fcaed63a80e74f10e656111526d859d279e1fb85d01
Changed on 2026-09-25

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.

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