X-iBubbl-Signature header. Verify it before you trust the body: it proves the request came from iBubbl, and its timestamp limits how long a captured request can be replayed against your endpoint.
Header format
t is the Unix time, in seconds, when iBubbl signed the request. v1 is the HMAC-SHA256 described below. The v1 label leaves room for a future scheme (v2) without a new header name.
Algorithm
- Function: HMAC-SHA256, output as lowercase hex.
- Key: the literal UTF-8 bytes of your whole subscription secret, the
whsec_…string, prefix included. Do not hex-decode it. - Message: the
tvalue, a literal., then the raw request body bytes:{t}.{raw body}.
What iBubbl signs
The body is the exact bytes iBubbl sent: compact JSON (no spaces), keys sorted, and every non-ASCII character escaped as\uXXXX, so an emoji 👍 arrives as \ud83d\udc4d. Don’t rely on that format: verify the bytes you received, and never re-serialize them. JSON.stringify(JSON.parse(body)) turns \ud83d\udc4d back into 👍 and the signature no longer matches.
If a delivery is retried, the retry carries the same body and the same event id, but a new t and a new signature: each attempt is signed when it is sent. See Retries and deduplication.
Replay protection
Reject the request if|now − t| > 300 seconds (5 minutes). Each attempt is signed fresh, so a retry is never rejected just because the original event is old.
Five minutes covers normal clock drift on NTP-synced servers. If your clock can drift further, fix the clock rather than widening the window.
Reference verifiers
Copy one of these. Both take the raw body bytes, the header value (orNone/undefined if it’s missing) and your secret, and return false for a missing, malformed, stale or wrong signature instead of throwing, so a bad request can’t crash your handler. The optional last argument is the current time in Unix seconds; leave it out in production, and pass it only to run the test vector.
Test vector
Use this to check your verifier without a webhook, a tunnel or a live secret. It was produced by iBubbl’s own signer. The envelope is cut down (real events carry more fields, andcreated_at is an ISO 8601 string), but the bytes follow the same rules as every real delivery: compact, sorted keys, \u escapes.
The
\ud83d\udc4d in the body is twelve literal characters (backslash, u, four hex digits, twice), not an emoji. Pass the time t as “now”, because the vector’s timestamp is long past the 5-minute window:
\u escapes were turned into an emoji, or whitespace was added along the way.
Framework integration
The signature only matches the exact bytes iBubbl sent, so read the raw body before any JSON parsing.Express — keep the raw body
Express — keep the raw body
express.json() parses the body and throws the bytes away. Either parse the webhook route with express.raw():express.json() is already mounted for the whole app, keep a copy of the bytes with its verify hook and verify req.rawBody:FastAPI / Starlette — read the body first
FastAPI / Starlette — read the body first
await request.body() returns the raw bytes. Read them before any parsing, and don’t declare a Pydantic body parameter on the route.Next.js (App Router) — read the bytes in the route handler
Next.js (App Router) — read the bytes in the route handler
Read the body as bytes with On the Pages Router, turn the built-in parser off for the route (
request.arrayBuffer() and verify them before parsing. await request.text() followed by Buffer.from(text, 'utf-8') gives the same bytes, but reading bytes directly is the safer habit.export const config = { api: { bodyParser: false } }) and read the request stream into a Buffer yourself.Common pitfalls
- Use a constant-time comparison:
hmac.compare_digest(Python) orcrypto.timingSafeEqual(Node), never==. - Don’t skip verification “for testing.” Test shortcuts reach production. Use the test vector instead.
- Reject a bad signature with
401and don’t process the body. Like any non-2xx, a401is a failed delivery: iBubbl retries it on the normal schedule and then marks it dead. If valid deliveries are being rejected, check the raw body, the fullwhsec_…secret and the header name’s case.
Next
Receive messages
A complete receiver, including an auto-reply that can’t loop.
Test webhooks locally
Receive real events on your laptop through a tunnel.

