Skip to main content
Every webhook iBubbl POSTs to your URL carries a signature in the 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

Example (from the test vector below):
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.
Header names are case-insensitive, and you’ll usually see this one in lower case. HTTP/2, proxies and most frameworks deliver it as x-ibubbl-signature. Read it case-insensitively:
  • Node / Express: req.headers['x-ibubbl-signature']. Node lower-cases every header name, so req.headers['X-iBubbl-Signature'] is undefined, and a verifier given undefined rejects every request. (req.get() and req.header() in Express match any case.)
  • Next.js / Fetch API: request.headers.get('x-ibubbl-signature') (Headers.get matches any case).
  • FastAPI / Starlette: request.headers.get('x-ibubbl-signature') (matches any case).

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 t value, 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 (or None/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, and created_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:
If the first check fails, compare the length first: a body that isn’t exactly 159 bytes means the \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.json() parses the body and throws the bytes away. Either parse the webhook route with express.raw():
Or, if express.json() is already mounted for the whole app, keep a copy of the bytes with its verify hook and verify req.rawBody:
await request.body() returns the raw bytes. Read them before any parsing, and don’t declare a Pydantic body parameter on the route.
Read the body as bytes with 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.
On the Pages Router, turn the built-in parser off for the route (export const config = { api: { bodyParser: false } }) and read the request stream into a Buffer yourself.

Common pitfalls

Don’t re-serialize the body before verifying. Any middleware that parses and re-stringifies JSON produces different bytes: spacing, key order and \u escapes all change. Only the raw bytes as received verify.
Don’t hex-decode the secret. The HMAC key is the whole string whsec_a3f2c8d9… encoded as UTF-8, not the decoded bytes of the hex part.
Don’t skip the timestamp check. The HMAC proves iBubbl sent the request; the timestamp stops a captured request from being replayed hours later. You need both.
  • Use a constant-time comparison: hmac.compare_digest (Python) or crypto.timingSafeEqual (Node), never ==.
  • Don’t skip verification “for testing.” Test shortcuts reach production. Use the test vector instead.
  • Reject a bad signature with 401 and don’t process the body. Like any non-2xx, a 401 is 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 full whsec_… 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.