Skip to main content
Every 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

X-Keebai-Signature: t=1714214100,v1=8f2c5b3a9d4e7c1f...
TokenMeaning
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.
The signed payload is the timestamp and the raw body concatenated with a dot, not the body alone. That stops anyone from taking an old valid body and resending it with a different timestamp.

Verification steps

1

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).
2

Parse the header

Pull t and v1 out of the X-Keebai-Signature header.
3

Rebuild the signed payload

signedPayload = ${t}.${rawBody}.
4

Compute HMAC-SHA256 with your secret

expected = HMAC-SHA256(secret, signedPayload).toString('hex').
5

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.
6

Anti-replay (optional but recommended)

Reject if now - t > 5 minutes. An attacker could capture an old delivery and replay it if your endpoint is public.

Examples

import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.KEEBAI_WEBHOOK_SECRET;
const app = express();

// Capture raw body for this route
app.post(
  '/keebai-events',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sigHeader = req.header('X-Keebai-Signature') || '';
    const parts = Object.fromEntries(
      sigHeader.split(',').map((p) => p.split('=')),
    );
    const t = parts.t;
    const v1 = parts.v1;
    if (!t || !v1) return res.sendStatus(401);

    // Anti-replay: 5 minutes
    const skewSeconds = Math.abs(Date.now() / 1000 - Number(t));
    if (skewSeconds > 300) return res.sendStatus(401);

    const rawBody = req.body.toString('utf8');
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    const ok = crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(v1, 'hex'),
    );
    if (!ok) return res.sendStatus(401);

    const event = JSON.parse(rawBody);
    // dedup by event.id before processing
    res.sendStatus(200);
  },
);

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:
1

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.
2

Rotate

keebai webhooks rotate <id>. Paste the new secret into KEEBAI_WEBHOOK_SECRET_NEXT and deploy.
3

Promote

Once you’ve confirmed the new one works, move it to KEEBAI_WEBHOOK_SECRET and remove the old one.
If your setup is simpler and you can accept a few minutes of failures during rotation, you can rotate and deploy in a single pass.

Common errors

SymptomLikely cause
401 on every deliveryWrong secret, or you’re signing only the body without the ${t}. prefix.
Works locally, fails in staging/prodThe framework is reserializing the body. Capture the raw bytes.
Intermittent failureYou’re comparing with ===. Switch to timingSafeEqual.
401 after rotatingYou didn’t update the secret in your env, or your app is still holding the old one cached.