Docs
Webhooks
ReserveBeat signs every webhook POST with an X-ReserveBeat-Signature header: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" under the endpoint secret>. Verify the raw bytes, reject a timestamp more than 300 seconds from your clock, and accept any matching v1 value.
Reviewed
On this page
An account registers an HTTPS endpoint and chooses the event kinds it receives: attestation.landed, when an attestation report lands or is restated for a token, and figure.moved, when an issuer's own stated figure for a token changes. Each registration returns a signing secret once; it is derived when needed and stored nowhere, so save it when it is shown. A registration may be scoped to one token by its symbol, in any letter case; the response names the symbol as the board tracks it, exactly as the listing does, and a symbol the board does not track is refused with the unknown_token code the rest of the API uses. An address that is not a single https URL, or is longer than the service accepts, is refused with a sentence saying which.
Headers
Every delivery is a POST with a JSON body and five headers. X-ReserveBeat-Signature carries the signature. X-ReserveBeat-Event names the kind (ping for a test). X-ReserveBeat-Delivery identifies this delivery; deduplicate on it, because delivery is at least once. X-ReserveBeat-Endpoint names the registration it was sent for. X-ReserveBeat-Replay-Of is present only on a replay you requested, and names the delivery it repeats.
The signature
The signature header has the form t=<unix seconds>,v1=<hex>. The v1 value is HMAC-SHA256, keyed with the endpoint secret (the whole string, whsec_ prefix included), over the timestamp, a full stop, and the raw request body. Compute it yourself and compare in constant time. The timestamp is inside the MAC, so a captured request cannot be sent to you again later under a new time.
Reject a request whose timestamp is more than 300 seconds away from your own clock, in either direction. Each retry and each replay is signed afresh at the moment it is sent, so a legitimate delivery is never older than that when it leaves.
Verify the raw bytes
Verify the bytes exactly as they arrived, before parsing them. The body is sent as RFC 8785 canonical JSON, and a body parsed and serialized again by a framework usually differs by a space or a key order, which fails verification even though nothing was tampered with. Most frameworks expose the raw body; read it from there.
Rotating a secret
Rotating a secret returns the new one and keeps the old one signing for 24 hours: every delivery in that window carries two v1 values, one per secret, so a receiver still holding the old secret keeps verifying while the new one is rolled out. Accept a request if any v1 value matches. A second rotation inside the window retires the secret it replaces. A rotation made after the service's own signing key changed has no overlap, and the rotation response says so.
Test ping
POST /account/webhooks/{id}/test sends one signed ping to an endpoint and answers with what the receiver said: whether it answered with a 2xx status, the status, and the reason if it did not. The ping carries kind ping and the endpoint's id, is signed exactly like a delivery (with both secrets during a rotation overlap), is one request with no retries, and does not count toward the failure run below. One ping per endpoint per 60 seconds. An account with no compute-unit balance can register one endpoint, which receives test pings only; event deliveries to it start once the account holds a balance or is on Enterprise. A webhook request the account cannot use yet is refused with 402 plan_required, and the refusal names what lifts it — a positive balance or the enterprise plan — and carries purchase_url and pricing_url, where compute units are bought.
Retries and auto-disable
A delivery is attempted up to three times, and an attempt fails once the receiver has sent nothing back for 4 seconds. A receiver that does not answer with a 2xx status fails that delivery, which is listed back on the account with the reason and can be replayed as the same bytes. An account may request 100 replays in any 24 hours; a request past that is refused with 429 rate_limited and a Retry-After header giving the seconds until the next one fits, and the refused request creates no replay. An endpoint is disabled automatically only after at least 14 failed deliveries in a row over at least 24 hours; the account is mailed a warning before that happens, and one successful delivery resets the count. Rotating the secret turns a disabled endpoint back on. The delivery list can be filtered to one endpoint; an endpoint id that is not one of the account's own is refused exactly as one that does not exist.
Verification code
Verification in Python and in Node, using only each standard library. Both return true only for a fresh timestamp and a matching v1 value, and both are checked against the service's own verifier and against the test vectors below on every build.
Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, secret: str, header: str,
tolerance_s: int = 300, now: float | None = None) -> bool:
"""Check an X-ReserveBeat-Signature header against the RAW request bytes."""
stamp, given = None, []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t":
try:
stamp = int(value)
except ValueError:
return False
elif key == "v1":
given.append(value)
if stamp is None or not given:
return False # no timestamp or no signature: reject
if abs((time.time() if now is None else now) - stamp) > tolerance_s:
return False # too old (or from the future): reject replays
expected = hmac.new(secret.encode(), f"{stamp}.".encode() + raw_body,
hashlib.sha256).hexdigest()
# any matching v1 passes: during a secret rotation there are two
return any(hmac.compare_digest(v, expected) for v in given)
Node
import { createHmac, timingSafeEqual } from "node:crypto";
// Check an X-ReserveBeat-Signature header against the RAW request bytes.
export function verify(rawBody, secret, header, toleranceS = 300,
now = Date.now() / 1000) {
let stamp = null;
const given = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") {
if (!/^\d+$/.test(value)) return false;
stamp = Number(value);
} else if (key === "v1") {
given.push(value);
}
}
// no timestamp or no signature: reject
if (stamp === null || given.length === 0) return false;
// too old (or from the future): reject replays
if (Math.abs(now - stamp) > toleranceS) return false;
const expected = createHmac("sha256", secret)
.update(`${stamp}.`)
.update(rawBody)
.digest();
// any matching v1 passes: during a secret rotation there are two
return given.some((v) => {
const got = Buffer.from(v, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Test vectors
Test vectors: a published test secret, a body, the header the service produced for it, the clock to check at, and whether it must verify. Run your own code against them; the secrets are published test values and sign nothing real.
[
{
"name": "valid",
"secret": "whsec_published_test_vector_new_secret",
"body": "{\"api_version\":\"v1\",\"data\":{\"endpoint_id\":\"00000000-0000-4000-8000-000000000000\"},\"id\":\"ping_00000000000000000000000000000000\",\"kind\":\"ping\",\"occurred_at\":\"2026-09-21T13:33:20+00:00\"}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e",
"now": 1790000010,
"valid": true
},
{
"name": "overlap: the retired secret verifies",
"secret": "whsec_published_test_vector_old_secret",
"body": "{\"api_version\":\"v1\",\"data\":{\"endpoint_id\":\"00000000-0000-4000-8000-000000000000\"},\"id\":\"ping_00000000000000000000000000000000\",\"kind\":\"ping\",\"occurred_at\":\"2026-09-21T13:33:20+00:00\"}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e,v1=6ee4e06f2a7e550a3be342ae8fc67a0397cd54b718092ecd478632859b3acbee",
"now": 1790000010,
"valid": true
},
{
"name": "overlap: the new secret verifies",
"secret": "whsec_published_test_vector_new_secret",
"body": "{\"api_version\":\"v1\",\"data\":{\"endpoint_id\":\"00000000-0000-4000-8000-000000000000\"},\"id\":\"ping_00000000000000000000000000000000\",\"kind\":\"ping\",\"occurred_at\":\"2026-09-21T13:33:20+00:00\"}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e,v1=6ee4e06f2a7e550a3be342ae8fc67a0397cd54b718092ecd478632859b3acbee",
"now": 1790000010,
"valid": true
},
{
"name": "one byte of the body changed",
"secret": "whsec_published_test_vector_new_secret",
"body": "{\"api_version\":\"v1\",\"data\":{\"endpoint_id\":\"00000000-0000-4000-8000-000000000000\"},\"id\":\"ping_00000000000000000000000000000000\",\"kind\":\"pong\",\"occurred_at\":\"2026-09-21T13:33:20+00:00\"}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e",
"now": 1790000010,
"valid": false
},
{
"name": "re-serialized body (spaces added)",
"secret": "whsec_published_test_vector_new_secret",
"body": "{\"id\": \"ping_00000000000000000000000000000000\", \"kind\": \"ping\", \"api_version\": \"v1\", \"occurred_at\": \"2026-09-21T13:33:20+00:00\", \"data\": {\"endpoint_id\": \"00000000-0000-4000-8000-000000000000\"}}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e",
"now": 1790000010,
"valid": false
},
{
"name": "timestamp older than 300 s",
"secret": "whsec_published_test_vector_new_secret",
"body": "{\"api_version\":\"v1\",\"data\":{\"endpoint_id\":\"00000000-0000-4000-8000-000000000000\"},\"id\":\"ping_00000000000000000000000000000000\",\"kind\":\"ping\",\"occurred_at\":\"2026-09-21T13:33:20+00:00\"}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e",
"now": 1790000301,
"valid": false
},
{
"name": "another endpoint's secret",
"secret": "whsec_published_test_vector_other_secret",
"body": "{\"api_version\":\"v1\",\"data\":{\"endpoint_id\":\"00000000-0000-4000-8000-000000000000\"},\"id\":\"ping_00000000000000000000000000000000\",\"kind\":\"ping\",\"occurred_at\":\"2026-09-21T13:33:20+00:00\"}",
"header": "t=1790000000,v1=8b98682f9fcceb508e099eace2d0cc07844df5e084e60444ed030032b71ee41e",
"now": 1790000010,
"valid": false
}
]