POST to your endpoint includes the X-Keebai-Signature header. If you don’t verify it, anyone who knows your endpoint URL can send fake payloads. Always verify.
Anatomy of the signature
| Token | Meaning |
|---|---|
t=<unix> | Unix seconds when the signature was generated. Used to protect against replay attacks. |
v1=<hex> | HMAC-SHA256 (hex) of the string ${t}.${rawBody}, computed with your secret. |
Verification steps
Capture the raw body
Before parsing JSON. If your framework parses automatically, configure it to keep the raw bytes too (in Express, use
bodyParser.raw for this route or read req.rawBody).Constant-time comparison
Compare
expected to v1 using a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). Don’t use plain ===: it’s vulnerable to timing attacks.Examples
Best practices
Raw body, not parsed
The HMAC is computed over the exact bytes. If your framework parses and reserializes the JSON, whitespace shifts and the signature stops matching.
Constant-time comparison
=== and == leak length information and enable timing attacks. Always use timingSafeEqual / hmac.compare_digest / hash_equals.Verify BEFORE any side-effect
Don’t process the event, don’t log details, don’t write to your DB until the signature is valid. Return 401 immediately on failure.
Dedup by `event.id`
We retry on 5xx / timeout / 408 / 429. Your endpoint may receive the same
id multiple times. Keep a table of processed event_id values (7-day TTL).Secret rotation
POST /v1/webhooks/:id/rotate-secret (or keebai webhooks rotate <id>) generates a new secret. The old one is invalidated immediately — there is no grace period. Recommended rotation strategy:
Configure your endpoint to accept 2 secrets at once
One variable
KEEBAI_WEBHOOK_SECRET (current) and another KEEBAI_WEBHOOK_SECRET_NEXT (staged). The handler tries the first one; if it fails, it tries the second.Rotate
keebai webhooks rotate <id>. Paste the new secret into KEEBAI_WEBHOOK_SECRET_NEXT and deploy.Common errors
| Symptom | Likely cause |
|---|---|
401 on every delivery | Wrong secret, or you’re signing only the body without the ${t}. prefix. |
| Works locally, fails in staging/prod | The framework is reserializing the body. Capture the raw bytes. |
| Intermittent failure | You’re comparing with ===. Switch to timingSafeEqual. |
| 401 after rotating | You didn’t update the secret in your env, or your app is still holding the old one cached. |