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

# Verifying signatures

> Check that every request really came from us, in five languages.

Your webhook URL is not a secret. It sits in logs, proxies, and integration tools. The signature is what proves a request came from us.

```text theme={null}
X-MyAIChatbot-Signature: v1=6e07b5c9
X-MyAIChatbot-Timestamp: 1752721512
```

## The steps

<Steps>
  <Step title="Take the raw body">
    The exact bytes we sent, before any JSON parsing.
  </Step>

  <Step title="Build the signed string">
    `{timestamp}.{raw_body}` — the timestamp header, a dot, then the body.
  </Step>

  <Step title="HMAC it">
    HMAC-SHA256 with your `whsec_` secret, as lowercase hex.
  </Step>

  <Step title="Compare">
    Add `v1=` in front and compare with the header, using a timing-safe compare.
  </Step>

  <Step title="Check the clock">
    Reject anything more than 300 seconds from now.
  </Step>
</Steps>

<Warning>
  **Sign the raw body, never a re-built object.** `JSON.parse` then `JSON.stringify` changes the bytes, and your signature will never match. `{"a":1}` and `{"a": 1}` give different signatures — that is the point.
</Warning>

## Code

<CodeGroup>
  ```javascript Node.js theme={null}
  import express from 'express'
  import { createHmac, timingSafeEqual } from 'node:crypto'

  const SECRET = process.env.WEBHOOK_SECRET
  const TOLERANCE = 300

  function verify(rawBody, timestamp, signature) {
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) return false

    const expected =
      'v1=' +
      createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex')

    const a = Buffer.from(expected)
    const b = Buffer.from(signature ?? '')
    return a.length === b.length && timingSafeEqual(a, b)
  }

  const app = express()

  // express.raw keeps the bytes intact — express.json would destroy them.
  app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
    const rawBody = req.body.toString('utf8')

    if (!verify(rawBody, req.header('X-MyAIChatbot-Timestamp'), req.header('X-MyAIChatbot-Signature'))) {
      return res.status(401).end()
    }

    const event = JSON.parse(rawBody)
    // handle it
    res.status(200).end()
  })
  ```

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

  SECRET = os.environ["WEBHOOK_SECRET"].encode()
  TOLERANCE = 300

  def verify(raw_body: bytes, timestamp: str, signature: str) -> bool:
      try:
          if abs(time.time() - int(timestamp)) > TOLERANCE:
              return False
      except (TypeError, ValueError):
          return False

      signed = timestamp.encode() + b"." + raw_body
      expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature or "")

  app = Flask(__name__)

  @app.post("/webhook")
  def webhook():
      # request.get_data() returns the untouched bytes
      if not verify(
          request.get_data(),
          request.headers.get("X-MyAIChatbot-Timestamp"),
          request.headers.get("X-MyAIChatbot-Signature"),
      ):
          return "", 401

      event = request.get_json()
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  $secret    = getenv('WEBHOOK_SECRET');
  $tolerance = 300;

  $rawBody   = file_get_contents('php://input');
  $timestamp = $_SERVER['HTTP_X_MYAICHATBOT_TIMESTAMP'] ?? '';
  $signature = $_SERVER['HTTP_X_MYAICHATBOT_SIGNATURE'] ?? '';

  if (abs(time() - (int) $timestamp) > $tolerance) {
      http_response_code(401);
      exit;
  }

  $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

  if (!hash_equals($expected, $signature)) {
      http_response_code(401);
      exit;
  }

  $event = json_decode($rawBody, true);
  http_response_code(200);
  ```

  ```go Go theme={null}
  package main

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

  const tolerance = 300

  func verify(rawBody []byte, timestamp, signature string) bool {
      ts, err := strconv.ParseInt(timestamp, 10, 64)
      if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > tolerance {
          return false
      }

      mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
      mac.Write([]byte(timestamp + "."))
      mac.Write(rawBody)
      expected := "v1=" + hex.EncodeToString(mac.Sum(nil))

      return hmac.Equal([]byte(expected), []byte(signature))
  }

  func webhook(w http.ResponseWriter, r *http.Request) {
      rawBody, _ := io.ReadAll(r.Body)

      if !verify(rawBody,
          r.Header.Get("X-MyAIChatbot-Timestamp"),
          r.Header.Get("X-MyAIChatbot-Signature")) {
          w.WriteHeader(http.StatusUnauthorized)
          return
      }

      w.WriteHeader(http.StatusOK)
  }
  ```

  ```bash curl theme={null}
  # Recompute a captured signature by hand (debugging only).
  TIMESTAMP="1752721512"
  RAW_BODY='{"event":"message.created"}'

  printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" \
    | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex \
    | sed 's/^.* /v1=/'

  # Compare with the X-MyAIChatbot-Signature header. Use printf, not echo:
  # echo adds a newline and changes the result.
  ```
</CodeGroup>

## Common mistakes

<AccordionGroup>
  <Accordion title="Signatures never match">
    You are almost surely signing a **re-built** body. `express.json()`, `bodyParser.json()`, and most framework middleware parse the request and throw the original bytes away. Get the raw body first — `express.raw()` in Node, `request.get_data()` in Flask, `php://input` in PHP.

    A trailing newline also breaks it. `echo` adds one; `printf` does not.
  </Accordion>

  <Accordion title="It works locally but fails in production">
    Check the clock. Outside the 300-second window, even a perfect signature is rejected. Containers with drifting clocks are the usual cause.
  </Accordion>

  <Accordion title="Header not found">
    Header names ignore case in HTTP, but some frameworks rename them. PHP shows them as `HTTP_X_MYAICHATBOT_SIGNATURE`. Some proxies strip headers they do not know.
  </Accordion>

  <Accordion title="Using == to compare">
    A plain `==` leaks tiny timing clues. In theory, an attacker can use them to guess a valid signature one byte at a time. Use `timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, or `hmac.Equal`.
  </Accordion>
</AccordionGroup>

## No-code platforms

n8n, Make, and Zapier cannot easily run an HMAC in a trigger step. In practice, you rely on the URL being hard to guess — so treat it like a password.

<Warning>
  If your bot does anything sensitive — refunds, account changes, private data — put a real server in front that checks the signature, then have it call your scenario. An unchecked webhook URL is a password that never changes.
</Warning>

## Changing the secret

Make a new secret in your bot's webhook settings. It starts working **right away**, with no overlap window. Deploy the new secret first, and switch at a quiet time.
