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

# Signature verification

> Verify HMAC-SHA256 signatures on incoming webhook deliveries.

Every webhook delivery carries an `X-Yotel-Signature` header. Your
handler **must** verify it before trusting the payload. Without
verification, anyone can POST a fake `call.ended` to your endpoint.

## The contract

```
signed_bytes = f"{timestamp}.".encode() + raw_request_body
expected     = "sha256=" + hmac_sha256(signing_secret, signed_bytes).hexdigest()
```

Reject the request if:

1. `X-Yotel-Signature` doesn't match `expected` (timing-safe compare), OR
2. `|now - X-Yotel-Timestamp| > 300` seconds (replay protection)

## Reference implementations

<CodeGroup>
  ```python Python (FastAPI) theme={null}
  import hmac, hashlib, time
  from fastapi import Request, HTTPException

  SIGNING_SECRET = os.environ["YOTEL_WEBHOOK_SECRET"]

  async def handle_webhook(request: Request):
      raw = await request.body()
      sig = request.headers.get("X-Yotel-Signature", "")
      ts = int(request.headers.get("X-Yotel-Timestamp", "0"))

      if abs(int(time.time()) - ts) > 300:
          raise HTTPException(401, "Replay window exceeded")

      signed = f"{ts}.".encode() + raw
      expected = "sha256=" + hmac.new(
          SIGNING_SECRET.encode(), signed, hashlib.sha256
      ).hexdigest()
      if not hmac.compare_digest(expected, sig):
          raise HTTPException(401, "Bad signature")

      # Safe to parse + process
      import json
      event = json.loads(raw)
      return {"ok": True}
  ```

  ```python Python (yotel SDK) theme={null}
  import yotel

  SIGNING_SECRET = os.environ["YOTEL_WEBHOOK_SECRET"]

  async def handle_webhook(request):
      raw = await request.body()
      try:
          event = yotel.webhook.verify_and_parse(
              raw,
              headers=request.headers,
              signing_secret=SIGNING_SECRET,
          )
      except yotel.webhook.InvalidSignature:
          raise HTTPException(401)
      # event is a typed Event
      ...
  ```

  ```typescript TypeScript (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const SIGNING_SECRET = process.env.YOTEL_WEBHOOK_SECRET!;

  app.post(
    "/webhooks/yotel",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const sig = (req.headers["x-yotel-signature"] as string) ?? "";
      const ts = parseInt(
        (req.headers["x-yotel-timestamp"] as string) ?? "0",
        10,
      );

      if (Math.abs(Date.now() / 1000 - ts) > 300) {
        return res.status(401).send("Replay");
      }

      const signed = Buffer.concat([Buffer.from(`${ts}.`), req.body]);
      const expected =
        "sha256=" +
        crypto.createHmac("sha256", SIGNING_SECRET).update(signed).digest("hex");

      const ok =
        expected.length === sig.length &&
        crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
      if (!ok) return res.status(401).send("Bad signature");

      const event = JSON.parse(req.body.toString());
      // ... handle event
      res.status(200).send();
    },
  );
  ```

  ```typescript TypeScript (@yotel/client) theme={null}
  import { verifyWebhook, InvalidSignature } from "@yotel/client";

  app.post("/webhooks/yotel", express.raw({ type: "application/json" }),
    (req, res) => {
      try {
        const event = verifyWebhook({
          rawBody: req.body,
          headers: req.headers,
          signingSecret: process.env.YOTEL_WEBHOOK_SECRET!,
        });
        // event is typed
      } catch (e) {
        if (e instanceof InvalidSignature) return res.status(401).send();
        throw e;
      }
      res.status(200).send();
    });
  ```

  ```go Go theme={null}
  import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
  )

  func VerifyYotelWebhook(r *http.Request, secret string) ([]byte, error) {
    raw, err := io.ReadAll(r.Body)
    if err != nil {
      return nil, err
    }
    sig := r.Header.Get("X-Yotel-Signature")
    tsStr := r.Header.Get("X-Yotel-Timestamp")
    ts, err := strconv.ParseInt(tsStr, 10, 64)
    if err != nil {
      return nil, fmt.Errorf("bad timestamp")
    }
    if abs(time.Now().Unix()-ts) > 300 {
      return nil, fmt.Errorf("replay window")
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(fmt.Sprintf("%d.", ts)))
    mac.Write(raw)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(sig)) {
      return nil, fmt.Errorf("bad signature")
    }
    return raw, nil
  }
  ```
</CodeGroup>

## Critical gotchas

<AccordionGroup>
  <Accordion title="Read the raw body BEFORE parsing JSON">
    Many frameworks (Express, FastAPI) auto-parse JSON bodies. If you
    sign the parsed-then-reserialized body, whitespace or key-order
    drift breaks the signature. Always sign the raw bytes.
  </Accordion>

  <Accordion title="Sync your server's clock">
    The 5-minute replay window requires NTP sync on your host. A drift
    of more than ±5 min rejects every delivery. If you see
    `401: Replay window exceeded`, check your server's clock first.
  </Accordion>

  <Accordion title="Use timing-safe compare">
    Don't use `==` to compare signatures — it short-circuits on the
    first different byte and leaks information to an attacker probing
    your endpoint. Use `hmac.compare_digest` / `crypto.timingSafeEqual`
    as shown above.
  </Accordion>

  <Accordion title="Handle duplicate deliveries (event_id)">
    Retries use the same `event_id`. Your handler should key on that
    — store a `seen_event_ids` set (Redis + TTL matches our 7.5h retry
    window) and skip duplicates. Skipping is fine: respond 200 without
    reprocessing.
  </Accordion>
</AccordionGroup>
