> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keebai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signature verification

> How to validate that each delivery actually came from Keebai using HMAC-SHA256 over the `X-Keebai-Signature` header.

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

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

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

<Steps>
  <Step title="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`).
  </Step>

  <Step title="Parse the header">
    Pull `t` and `v1` out of the `X-Keebai-Signature` header.
  </Step>

  <Step title="Rebuild the signed payload">
    `signedPayload = ${t}.${rawBody}`.
  </Step>

  <Step title="Compute HMAC-SHA256 with your secret">
    `expected = HMAC-SHA256(secret, signedPayload).toString('hex')`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Examples

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  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);
    },
  );
  ```

  ```python Python (Flask) theme={null}
  import hmac
  import hashlib
  import time
  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = b'whsec_xxx'  # bytes

  @app.post('/keebai-events')
  def handler():
      sig_header = request.headers.get('X-Keebai-Signature', '')
      parts = dict(p.split('=', 1) for p in sig_header.split(','))
      t = parts.get('t')
      v1 = parts.get('v1')
      if not t or not v1:
          abort(401)

      if abs(time.time() - int(t)) > 300:
          abort(401)

      raw_body = request.get_data()
      signed = f"{t}.".encode() + raw_body
      expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

      if not hmac.compare_digest(expected, v1):
          abort(401)

      event = request.get_json()
      # dedup by event['id']
      return '', 200
  ```

  ```php PHP theme={null}
  <?php
  $secret = getenv('KEEBAI_WEBHOOK_SECRET');
  $rawBody = file_get_contents('php://input');
  $sigHeader = $_SERVER['HTTP_X_KEEBAI_SIGNATURE'] ?? '';

  $parts = [];
  foreach (explode(',', $sigHeader) as $p) {
      [$k, $v] = explode('=', $p, 2);
      $parts[$k] = $v;
  }
  $t = $parts['t'] ?? null;
  $v1 = $parts['v1'] ?? null;
  if (!$t || !$v1) {
      http_response_code(401); exit;
  }

  if (abs(time() - (int) $t) > 300) {
      http_response_code(401); exit;
  }

  $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
  if (!hash_equals($expected, $v1)) {
      http_response_code(401); exit;
  }

  $event = json_decode($rawBody, true);
  // dedup by $event['id']
  http_response_code(200);
  ```

  ```go Go (net/http) theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  var secret = []byte("whsec_xxx")

  func handler(w http.ResponseWriter, r *http.Request) {
  	body, _ := io.ReadAll(r.Body)
  	sigHeader := r.Header.Get("X-Keebai-Signature")
  	parts := map[string]string{}
  	for _, p := range strings.Split(sigHeader, ",") {
  		kv := strings.SplitN(p, "=", 2)
  		parts[kv[0]] = kv[1]
  	}
  	t, v1 := parts["t"], parts["v1"]
  	if t == "" || v1 == "" {
  		w.WriteHeader(401); return
  	}
  	ts, _ := strconv.ParseInt(t, 10, 64)
  	if abs(time.Now().Unix()-ts) > 300 {
  		w.WriteHeader(401); return
  	}
  	mac := hmac.New(sha256.New, secret)
  	mac.Write([]byte(t + "."))
  	mac.Write(body)
  	expected := hex.EncodeToString(mac.Sum(nil))
  	if !hmac.Equal([]byte(expected), []byte(v1)) {
  		w.WriteHeader(401); return
  	}
  	// dedup, process...
  	w.WriteHeader(200)
  }

  func abs(n int64) int64 { if n < 0 { return -n }; return n }
  ```
</CodeGroup>

## Best practices

<CardGroup cols={2}>
  <Card title="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.
  </Card>

  <Card title="Constant-time comparison">
    `===` and `==` leak length information and enable timing attacks. Always use `timingSafeEqual` / `hmac.compare_digest` / `hash_equals`.
  </Card>

  <Card title="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.
  </Card>

  <Card title="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).
  </Card>
</CardGroup>

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="Rotate">
    `keebai webhooks rotate <id>`. Paste the new secret into `KEEBAI_WEBHOOK_SECRET_NEXT` and deploy.
  </Step>

  <Step title="Promote">
    Once you've confirmed the new one works, move it to `KEEBAI_WEBHOOK_SECRET` and remove the old one.
  </Step>
</Steps>

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

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