Contents
Webhooks
Signed HTTPS POSTs of every radar event to your endpoint within seconds of detection: whale moves, big liquidations, cascades, funding extremes and flips, OI spikes, whales near liquidation, records, exploits, depegs, TVL drops and CEX outflows.
https://your-server.example/hooks/blockmetricAPI Dev · 2 endpointsAPI Pro · 10 endpointsSet up in four steps
- Create an endpoint on Account › Event webhooks. Create endpoints on /account/webhooks: URL, event kinds (none = all), minimum severity and an optional coin list (with a coin filter, events without a coin are not sent). The signing secret (whsec_ + 43 characters) is shown once; rotation keeps the old secret signing for 24 hours. Up to 2 endpoints per account with API Dev, 10 with API Pro. "Send test event" delivers a type "test" event immediately.
- Verify every delivery with the signing secret (code below). Reject anything that does not verify.
- Answer 2xx within 10 seconds, then do the work. Deliveries are at-least-once: deduplicate on
Blockmetric-Event-Id. - Send a test event from the account page: a
testevent signed like a real one arrives at once.
Verify the signature
Blockmetric-Signature: t=<unix seconds>,v1=<64 hex chars>[,v1=<64 hex chars>], an HMAC-SHA256 of "<t>.<raw body>" with your signing secret.
import crypto from 'node:crypto'
// rawBody = the exact bytes received (Buffer), header = the Blockmetric-Signature header
export function verify(rawBody, header, secret, now = Date.now()) {
let t = NaN
const sigs = []
for (const part of header.split(',')) {
const [k, v] = part.trim().split('=')
if (k === 't') t = Number(v)
if (k === 'v1') sigs.push(v)
}
if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > 300) return false
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)
})
}- Compute the HMAC over the raw body bytes as received. Do not parse and re-serialise the JSON first.
- Accept the request when any v1 value matches (constant-time comparison); ignore unknown keys (future versions such as v2).
- Reject timestamps more than 300 seconds away from your clock (replay protection); keep your server clock in sync (NTP).
- Retries are signed again with a fresh t; the body is byte-identical.
Receivers per framework
The signature covers the exact bytes. Each framework has its own way to get the raw body. Here it is.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const seen = new Set() // use your database in production
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
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)
})
}
// express.raw keeps the exact bytes: do not put express.json() in front of this route
app.post('/hooks/blockmetric', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('Blockmetric-Signature'), process.env.BLOCKMETRIC_WEBHOOK_SECRET)) return res.status(400).send('invalid signature')
const event = JSON.parse(req.body.toString('utf8'))
res.status(204).end() // answer within 10 s, work afterwards
if (seen.has(event.id)) return // at-least-once delivery: deduplicate on the id
seen.add(event.id)
console.log(event.type, event.data.severity, event.data.title)
})
app.listen(3000)Request
POST with Content-Type application/json. Body: the event envelope, at most 64 KiB.
| Header | Meaning |
|---|---|
Blockmetric-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>; during a secret rotation a second v1 signed with the previous secret follows. |
Blockmetric-Event-Id | Same as the body id ("evt_<id>"); identical on every retry. |
Blockmetric-Event-Type | Same as the body type. |
Blockmetric-Delivery-Attempt | 1 for the first attempt, up to 6. |
User-Agent | Blockmetric-Webhooks/1.0 (+<site>) |
| Field | Description |
|---|---|
idstring | "evt_<radar event id>": stable across retries and identical to Blockmetric-Event-Id; use it to deduplicate. Test events: "evt_test_<hex>". |
typestring | The event kind (one of EVENT_TYPES) or "test". |
createdinteger | Unix seconds of the event (data.ts). |
createdAtstring | The same moment as ISO 8601 (API 1.14.0). Every other time in the API is ISO. |
dataobject | Exactly one item of GET /api/v1/radar/events: id, ts, kind, severity (1–4), coin, title, body, data, source. data.truncated: true when an oversized event was shortened (fetch it via /radar/events). |
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"
}
}type is one of whale_open, whale_close, whale_liquidated, whale_near_liq, big_liquidation, liq_cascade, funding_extreme, funding_flip, oi_spike, record, smart_money, hack, depeg, tvl_drop, cex_outflow, hl_market_change, hl_manipulation, hl_adl, oracle_deviation, vault_drawdown, liq_extreme, protocol_outflow, incident, big_transfer, stablecoin_mint, stablecoin_burn, dex_whale_trade, risk_wallet_move, risk_wallet_listed or test. Filter by kind, severity and coin per endpoint.
Delivery and retries
- Success
- Any 2xx status within 10 seconds. The response body is ignored (the first 2 KiB are shown in the delivery log).
- Retries
- Attempt 1 immediately, then 1 min, 5 min, 30 min, 2 h and 12 h after the previous failure (6 attempts over ~15 h). Timeouts, network errors, 3xx, 4xx (except 410) and 5xx count as failures.
- Disabling
- HTTP 410 Gone disables the endpoint immediately. 50 consecutive failed attempts disable the endpoint. Disabling cancels queued deliveries; enabling again resets the failure counter and delivers only new events.
- Your URL
- https:// on port 443, host names that resolve to public addresses only (no private, loopback, link-local or reserved ranges; checked on every connection). Redirects are not followed.
- Order
- Usually in event order per endpoint, but not guaranteed (retries), so order by data.ts / data.id. At-least-once delivery: deduplicate on the envelope id.
- Volume
- Up to 300 delivery attempts per account and minute; the rest follows in the next cycles.
| Delivery log shows | Fix |
|---|---|
| Timeout (no answer within 10 s) | Answer 2xx first, then process the event in the background or a queue. |
| 3xx redirect | Redirects are not followed. Register the final https:// URL. |
| 400 from your server | Usually a failed signature check: verify the raw bytes (no JSON parser in front), the right secret, and a synced clock. |
| 401 / 403 from your server | Your auth middleware blocks the route. Exclude the webhook path; the signature is the authentication. |
| 410 Gone | Disables the endpoint on purpose. Enable it again on the account page. |
| 5xx or network error | Retried up to 6 times over about 15 hours; 50 failures in a row disable the endpoint. |
| TLS error | A valid certificate for the host name on port 443. Self-signed certificates and private addresses are refused. |
Prefer one open connection to many deliveries? The live stream sends the same envelope over Server-Sent Events.
Code snippets in these docs are licensed under MIT-0: copy them into your own code without attribution. The SDKs, CLI and MCP server will be, once published, Apache-2.0; the name Blockmetric is not licensed (forks must be renamed). Data from the API is governed by the API terms; third-party exchange data by the exchange’s terms.