Cascade and cluster alerts with webhooks
Liquidation cascades pushed to your server, plus a warning before price hits a big cluster.
What you get
Your server receives signed webhooks for liquidation cascades and big liquidations, checks the signature and forwards an alert. Next to it, a five-minute check of the heatmap warns when the price comes within 2 % of a large cluster of liquidation prices, the levels where the next cascade could start.
[cluster] BTC: $32.4M of shorts liquidate at 81,500 (0.02 % from the mark 81,486)
[cluster] ETH: no large cluster within 2 %, nearest $24.2M at 2,190 (-7.7 %)
Listening on http://localhost:5296/hooks/blockmetric
[test] severity 1: Test event: your receiver verifies signatures
self-test: valid signature answered 204, forged signature answered 400Real output of the program below, run against our API on 2026-10-28 10:38 UTC.
Build it step by step
Run the receiver
Start receiver.mjs or receiver.py on your server. It listens on /hooks/blockmetric, reads the raw body, verifies Blockmetric-Signature and answers 204 before doing any work.
Create the endpoint
On Account › Webhooks add https://<your host>/hooks/blockmetric, choose the kinds liq_cascade and big_liquidation, a minimum severity and optional coins. Copy the signing secret (whsec_…, shown once) into BLOCKMETRIC_WEBHOOK_SECRET.
Test it before real events arrive
"Send test event" on the endpoint delivers a signed event of type test right away. Locally, SELF_TEST=1 makes the receiver sign and send one to itself, plus a forged one that must fail.
BLOCKMETRIC_WEBHOOK_SECRET=whsec_... SELF_TEST=1 node receiver.mjs # self-test: valid signature answered 204, forged signature answered 400Add the cluster warning
With BLOCKMETRIC_API_KEY set, the receiver asks every five minutes for the clusters of the newest heatmap column (clusters=10 returns them ready-made, no grid parsing) and warns once per cluster when the mark is within CLUSTER_DISTANCE.
curl -s -H "Authorization: Bearer $BLOCKMETRIC_API_KEY" \ "$BLOCKMETRIC_BASE_URL/heatmap/BTC?tf=15m&hours=1&clusters=5"Forward the alert
Set ALERT_WEBHOOK_URL to a Slack incoming webhook, or replace alert() with your own code: a database row, a pager, an order system.
The complete receiver
Set this once in the shell that runs the code:
export BLOCKMETRIC_API_KEY=bm_live_… # create one on /account/api
export BLOCKMETRIC_BASE_URL=https://api.blockmetric.io/v1// Cascade and cluster alerts: receives signed Blockmetric webhooks (liquidation cascades, big liquidations) and warns when
// the price comes close to a large liquidation cluster of the heatmap. Node 18+, no dependencies.
//
// export BLOCKMETRIC_WEBHOOK_SECRET=whsec_... # the endpoint's signing secret from Account > API keys
// export BLOCKMETRIC_API_KEY=bm_live_... BLOCKMETRIC_BASE_URL=https://<domain>/api/v1 # for the cluster check
// export ALERT_WEBHOOK_URL=https://hooks.slack.com/... # optional: Slack incoming webhook
// node receiver.mjs # listens on http://localhost:3000/hooks/blockmetric
//
// Options: PORT (3000), COINS (BTC,ETH), CLUSTER_MIN_USD (5000000), CLUSTER_DISTANCE (0.02 = 2 %), CLUSTER_MINUTES (5),
// SELF_TEST=1 (sends itself one signed test event, checks it and exits), ONCE=1 (one cluster check, then exit).
import crypto from 'node:crypto'
import http from 'node:http'
const SECRET = process.env.BLOCKMETRIC_WEBHOOK_SECRET ?? ''
const BASE = (process.env.BLOCKMETRIC_BASE_URL ?? '').replace(/\/+$/, '')
const KEY = process.env.BLOCKMETRIC_API_KEY
const PORT = Number(process.env.PORT ?? 3000)
const COINS = (process.env.COINS ?? 'BTC,ETH').split(',').map((c) => c.trim()).filter(Boolean)
const CLUSTER_MIN_USD = Number(process.env.CLUSTER_MIN_USD ?? 5_000_000)
const CLUSTER_DISTANCE = Number(process.env.CLUSTER_DISTANCE ?? 0.02)
const CLUSTER_MINUTES = Number(process.env.CLUSTER_MINUTES ?? 5)
const usd = (n) => (n >= 1e9 ? `$${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n / 1e6).toFixed(1)}M` : `$${(n / 1e3).toFixed(0)}K`)
/** Blockmetric-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>[,v1=...]. */
export function verify(rawBody, header, secret, nowSec = Math.floor(Date.now() / 1000)) {
let t = NaN
const sigs = []
for (const part of String(header ?? '').split(',')) {
const [k, v] = part.trim().split('=')
if (k === 't') t = Number(v)
if (k === 'v1' && v) sigs.push(v)
}
if (!Number.isInteger(t) || Math.abs(nowSec - t) > 300) return false // replay protection: 5 minutes
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest()
return sigs.some((s) => {
const got = Buffer.from(s, 'hex')
return got.length === expected.length && crypto.timingSafeEqual(got, expected)
})
}
const sent = new Set() // event ids and cluster keys already alerted (use your database in production)
async function alert(key, text) {
if (sent.has(key)) return
sent.add(key)
console.log(text)
const url = process.env.ALERT_WEBHOOK_URL
if (url) await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, content: text }) }).catch((e) => console.error(`alert failed: ${e.message}`))
}
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/hooks/blockmetric') return void res.writeHead(404).end()
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const raw = Buffer.concat(chunks) // the exact bytes: never parse and re-serialise before verifying
if (!verify(raw, req.headers['blockmetric-signature'], SECRET)) return void res.writeHead(400).end('invalid signature')
res.writeHead(204).end() // answer within 10 s, work afterwards
const ev = JSON.parse(raw.toString('utf8'))
const e = ev.data
void alert(ev.id, `[${ev.type}] severity ${e.severity}${e.coin ? ` ${e.coin}` : ''}: ${e.title}`)
})
})
/** Large clusters of the newest heatmap column that the mark is close to (data.clusters, no grid parsing needed). */
async function checkClusters() {
for (const coin of COINS) {
const res = await fetch(`${BASE}/heatmap/${encodeURIComponent(coin)}?tf=15m&hours=1&clusters=10`, { headers: KEY ? { Authorization: `Bearer ${KEY}` } : { 'X-Blockmetric-Access': 'public' } })
const body = await res.json()
if (!res.ok) {
console.error(`heatmap ${coin}: ${body.error?.code} ${body.error?.message}`)
continue
}
const c = body.data.clusters
if (!c) continue
const near = [...c.above, ...c.below].filter((x) => x.usd >= CLUSTER_MIN_USD && Math.abs(x.distance) <= CLUSTER_DISTANCE)
for (const x of near) await alert(`cluster:${coin}:${x.price}`, `[cluster] ${coin}: ${usd(x.usd)} of ${x.shortUsd > x.longUsd ? 'shorts' : 'longs'} liquidate at ${x.price.toLocaleString('en-US')} (${(x.distance * 100).toFixed(2)} % from the mark ${c.mark.toLocaleString('en-US')})`)
if (!near.length) {
const nearest = [...c.above, ...c.below].filter((x) => x.usd >= CLUSTER_MIN_USD).sort((a, b) => Math.abs(a.distance) - Math.abs(b.distance))[0]
console.log(`[cluster] ${coin}: no large cluster within ${CLUSTER_DISTANCE * 100} %${nearest ? `, nearest ${usd(nearest.usd)} at ${nearest.price.toLocaleString('en-US')} (${(nearest.distance * 100).toFixed(1)} %)` : ''}`)
}
}
}
if (process.env.ONCE === '1') {
await checkClusters()
} else {
if (!SECRET.startsWith('whsec_')) throw new Error('Set BLOCKMETRIC_WEBHOOK_SECRET (whsec_...), the signing secret of your endpoint.')
server.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}/hooks/blockmetric`))
if (BASE && CLUSTER_MINUTES > 0) {
void checkClusters()
setInterval(() => void checkClusters().catch((e) => console.error(e.message)), CLUSTER_MINUTES * 60_000)
}
if (process.env.SELF_TEST === '1') {
// A signed test delivery to ourselves, exactly as Blockmetric signs it: proves the secret and the raw-body handling.
const body = JSON.stringify({ id: `evt_test_${crypto.randomBytes(4).toString('hex')}`, type: 'test', created: Math.floor(Date.now() / 1000), data: { severity: 1, coin: null, title: 'Test event: your receiver verifies signatures' } })
const t = Math.floor(Date.now() / 1000)
const sig = crypto.createHmac('sha256', SECRET).update(`${t}.${body}`).digest('hex')
const ok = await fetch(`http://localhost:${PORT}/hooks/blockmetric`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Blockmetric-Signature': `t=${t},v1=${sig}` }, body })
const bad = await fetch(`http://localhost:${PORT}/hooks/blockmetric`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Blockmetric-Signature': `t=${t},v1=${'0'.repeat(64)}` }, body })
console.log(`self-test: valid signature answered ${ok.status}, forged signature answered ${bad.status}`)
setTimeout(() => process.exit(ok.status === 204 && bad.status === 400 ? 0 : 1), 200)
}
}
Run it:
node receiver.mjs
python receiver.py
ONCE=1 node receiver.mjs # one cluster check, then exitDownload: receiver.mjs · receiver.py · requests.sh. MIT-0: use it as you like.
Real responses
What the API answered when we ran this recipe, with long lists shortened.
Clusters of the newest heatmap column: GET /heatmap/BTC?tf=15m&hours=1&clusters=3
{
"data": {
"coin": "BTC",
"tf": "15m",
"clusters": {
"time": "2026-10-28T09:45:00.000Z",
"mark": 81486,
"above": [
{
"price": 91500,
"usd": 104183604,
"longUsd": 0,
"shortUsd": 104183604,
"distance": 0.122892276
},
{
"price": 97500,
"usd": 92616070,
"longUsd": 0,
"shortUsd": 92616070,
"distance": 0.1965245564
},
{
"price": 89500,
"usd": 70949011,
"longUsd": 0,
"shortUsd": 70949011,
"distance": 0.0983481825
}
],
"below": [
{
"price": 70000,
"usd": 103656298,
"longUsd": 103656298,
"shortUsd": 0,
"distance": -0.1409567288
},
{
"price": 57000,
"usd": 86309698,
"longUsd": 86309698,
"shortUsd": 0,
"distance": -0.3004933363
},
{
"price": 69000,
"usd": 82540304,
"longUsd": 82540304,
"shortUsd": 0,
"distance": -0.1532287755
}
]
}
},
"meta": {
"updatedAt": "2026-10-28T09:50:00.000Z",
"sources": [
"hyperliquid"
],
"plan": "api_dev",
"delayMinutes": 0,
"attribution": {
"text": "Data by Blockmetric · Source: Hyperliquid",
"url": "https://blockmetric.io/"
},
"license": {
"id": "internal",
"attributionRequired": false
}
}
}How the signature works
- Blockmetric-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256 with your signing secret over "<t>." followed by the raw body bytes. Compare in constant time and reject timestamps more than 300 seconds off.
- During a secret rotation a second v1 (signed with the previous secret) follows for 24 hours; accept the request when any v1 matches.
- Verify the exact bytes you received. Parsing the JSON and serialising it again changes the bytes and the check fails.
A webhook body (a real event)
{
"id": "evt_5468",
"type": "funding_extreme",
"created": 1793106660,
"createdAt": "2026-10-27T13:11:00.000Z",
"data": {
"id": 5468,
"ts": "2026-10-27T13:11:00.282Z",
"kind": "funding_extreme",
"severity": 1,
"coin": "xyz:PURRDAT",
"title": "xyz:PURRDAT funding at +128% APR on Hyperliquid",
"body": "Hourly rate +0.0146% (longs pay shorts) · open interest $11.6M · mark $9.2451.",
"data": {
"venue": "hyperliquid",
"fundingApr": 1.28301588,
"fundingHourly": 0.000146463,
"openInterestUsd": 11556929.71,
"mark": 9.2451
},
"source": "hyperliquid"
}
}Rate limits and pagination
- Deliveries are at-least-once: the same event id can arrive twice. Deduplicate on the id.
- Answer within 10 seconds with any 2xx. A failed delivery is tried up to 6 times over about 15 hours; 50 failed attempts in a row switch the endpoint off.
- The cluster check costs one request per coin every five minutes (288 a day per coin).
- Store processed event ids (a unique index is enough) so a retry never alerts twice.
- Use a coin filter on the endpoint instead of filtering in code: fewer deliveries, fewer retries.
- Backfill after downtime with GET /radar/events?since=<last event time>: same events, same envelope data.
Common pitfalls
- Body parsers in front of the route (express.json, a framework default) destroy the raw body. Read it raw on this one route.
- A receiver that does slow work before answering times out and gets the same event again. Answer first.
- A cluster close to the mark is not a prediction: positions get closed or topped up before price arrives.
Who it is for
- Desks and bots that must react within seconds
- Teams that already run a backend and want push instead of polling
- Anyone who wants to know where forced selling or buying would start
Plan and limits
Honest limits
- The heatmap is built from tracked Hyperliquid positions (and tracked Lighter accounts with venue=lighter), not from estimates of exchange positions we cannot see.
- Cascade events sum the liquidations of every collected venue; amounts per exchange are left out while the Binance and Bybit licence review is pending.
- A free key covers the heatmap of every coin for the last 24 hours; a longer window answers 400 invalid_param, Pro reads a year.
Example projects
A dependency-light receiver for Blockmetric webhooks (API Pro): reads the raw body, verifies Blockmetric-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)> with the SDK (constant-time, 300 s tolerance, accepts the second v1 during a secret rotation), answers 204 immediately and deduplicates on the event id (deliveries are at-least-once and retried up to 6 times).
Alerts on big liquidations and liquidation cascades as they happen, and warns once per cluster when the price comes close to a large liquidation cluster of the heatmap (built from real tracked Hyperliquid positions). Alerts go to the console and, optionally, to any JSON webhook (ALERT_WEBHOOK_URL, e.g. a Slack incoming webhook).
The Python twin of liquidation-alert-bot: alerts on big liquidations and liquidation cascades, and warns once per cluster when the price comes close to a large liquidation cluster of the heatmap. Alerts go to stdout and, optionally, to any JSON webhook (ALERT_WEBHOOK_URL, e.g. a Slack incoming webhook). Standard library plus the blockmetric SDK.
These projects use the official SDKs, which are being prepared for release. Until then the dependency-free program above runs as it is.
Next steps
More recipes like this
/liquidations/meWhale position trackerOne line every time a large wallet opens, adds, cuts, flips or closes a position.Free15 min to build/whales/feed/whales+2Wallet liquidation-risk monitorA warning when a watched position comes within 10 % or 3 % of its liquidation price.Free15 min to build/wallets/{address}/whales+1