Cascade and cluster alerts with webhooks

Liquidation cascades pushed to your server, plus a warning before price hits a big cluster.

Cheapest plan
API Dev$49/mo
Time to build
30 min
Endpoints
3
webhooks · /radar/events · /heatmap/{coin}
Languages
2
Node 18+, Python 3.9+, no installs

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 400

Real output of the program below, run against our API on 2026-10-28 10:38 UTC.

Build it step by step

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

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

  3. 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 400
  4. Add 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"
  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 exit

Download: 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"
  }
}

Webhooks guide · Live stream

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

API DevCheapest$49/mo
Webhooks (2 endpoints), the heatmap of every coin, internal use in your organisation.
60/min, 50,000/day, 365 days of history
API Pro$249/mo
10 endpoints, the live stream as a second path and the display licence.
300/min, 500,000/day, 1095 days of history
Free
The cluster check alone works with a free key for every coin (heatmap of the last 24 hours); events then come from polling GET /radar/events.
10/min, 1,000/day, 30 days of history

Compare plans

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

node-webhook-receiver

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

README.md · server.mjs · package.json

liquidation-alert-bot

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

README.md · bot.mjs · package.json

python-liquidation-alert-bot

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.

README.md · bot.py

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

LiveHyperliquid markets 10sOperationalHyperliquid positions & whales 8sOperationalLiquidation heatmap & levels 16sOperationalLighter markets & liquidations 21sOperationalLighter positions & heatmap 9sOperationalLighter RH positions & heatmap 10sOperationalBinance markets 7sOperationalBybit markets 29sOperationalBinance liquidations 21sOperationalBybit liquidations 21sOperationaldYdX positions & heatmap 9sOperationalGMX positions & heatmap 21sOperationalLong/short & open interest history 52sOperationalOrder books & trade flow 11sOperationalOn-chain transfers 9sOperationalSecurity monitor 58sOperationalBlockmetric Sentiment 8mOperationalOfficial news 1mOperationalAlert delivery 10sOperational10:20:11 UTCData: Blockmetric Research · Binance · Bybit