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

# Webhook signature verification with HMAC-SHA256

> Verify Yoshi webhook authenticity with HMAC-SHA256 signatures. Includes SDK helpers, manual verification, and replay protection examples.

Every webhook request from Yoshi is signed with HMAC-SHA256 so you can verify it came from Yoshi and hasn't been tampered with. Always verify signatures before processing events.

## Quick start

The fastest way to verify webhooks is with the SDK:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { verifySignature } from "@yoshi-ai/sdk/lib/webhooks";

  app.post("/webhooks/yoshi", (req, res) => {
    const isValid = verifySignature(
      req.body,
      req.headers["x-yoshi-signature"],
      req.headers["x-yoshi-timestamp"],
      process.env.WEBHOOK_SECRET,
    );

    if (!isValid) return res.status(400).send("Invalid signature");

    const event = JSON.parse(req.body);
    // Process event asynchronously...
    res.status(200).send("OK");
  });
  ```

  ```python Python theme={null}
  from yoshi.lib.webhooks import verify_signature

  @app.post("/webhooks/yoshi")
  def handle_webhook(request):
      is_valid = verify_signature(
          payload=request.body,
          signature=request.headers["x-yoshi-signature"],
          timestamp=request.headers["x-yoshi-timestamp"],
          secret=os.environ["WEBHOOK_SECRET"],
      )

      if not is_valid:
          return Response(status_code=400)

      event = request.json()
      # Process event asynchronously...
      return Response(status_code=200)
  ```
</CodeGroup>

## How signing works

Every webhook request includes two headers:

| Header              | Example           | Description                                      |
| ------------------- | ----------------- | ------------------------------------------------ |
| `x-yoshi-signature` | `a1b2c3d4e5f6...` | HMAC-SHA256 hex digest of the signed content     |
| `x-yoshi-timestamp` | `1714444800`      | Unix timestamp (seconds) when the event was sent |

The signature is computed as:

```
signed_content = "{timestamp}.{raw_body}"
signature = hex(HMAC-SHA256(secret, signed_content))
```

Where `secret` is your webhook signing secret (starts with `whsec_`), `timestamp` is the value of the `x-yoshi-timestamp` header, and `raw_body` is the raw request body string.

<Warning>
  You must use the **raw request body** for verification, not a parsed-then-serialized version. Most frameworks parse JSON automatically — make sure you capture the raw body before parsing.
</Warning>

## Framework examples

<CodeGroup>
  ```typescript Express theme={null}
  import express from "express";
  import { verifySignature } from "@yoshi-ai/sdk/lib/webhooks";

  const app = express();

  // Capture raw body for signature verification
  app.post("/webhooks/yoshi", express.raw({ type: "application/json" }), (req, res) => {
    const isValid = verifySignature(
      req.body,
      req.headers["x-yoshi-signature"] as string,
      req.headers["x-yoshi-timestamp"] as string,
      process.env.WEBHOOK_SECRET!,
    );

    if (!isValid) return res.status(400).send("Invalid signature");

    const event = JSON.parse(req.body.toString());
    console.log(`Received ${event.type}:`, event.id);
    res.status(200).send("OK");
  });
  ```

  ```typescript Next.js (App Router) theme={null}
  // app/api/webhooks/yoshi/route.ts
  import { verifySignature } from "@yoshi-ai/sdk/lib/webhooks";

  export async function POST(request: Request) {
    const body = await request.text();

    const isValid = verifySignature(
      body,
      request.headers.get("x-yoshi-signature")!,
      request.headers.get("x-yoshi-timestamp")!,
      process.env.WEBHOOK_SECRET!,
    );

    if (!isValid) {
      return new Response("Invalid signature", { status: 400 });
    }

    const event = JSON.parse(body);
    console.log(`Received ${event.type}:`, event.id);
    return new Response("OK", { status: 200 });
  }
  ```

  ```python FastAPI theme={null}
  from fastapi import FastAPI, Request, Response
  from yoshi.lib.webhooks import verify_signature
  import os

  app = FastAPI()

  @app.post("/webhooks/yoshi")
  async def handle_webhook(request: Request):
      body = await request.body()

      is_valid = verify_signature(
          payload=body,
          signature=request.headers["x-yoshi-signature"],
          timestamp=request.headers["x-yoshi-timestamp"],
          secret=os.environ["WEBHOOK_SECRET"],
      )

      if not is_valid:
          return Response(status_code=400)

      event = await request.json()
      print(f"Received {event['type']}: {event['id']}")
      return Response(status_code=200)
  ```

  ```python Django theme={null}
  # views.py
  import json
  from django.http import HttpResponse
  from django.views.decorators.csrf import csrf_exempt
  from yoshi.lib.webhooks import verify_signature
  import os

  @csrf_exempt
  def webhook_handler(request):
      is_valid = verify_signature(
          payload=request.body,
          signature=request.headers["X-Yoshi-Signature"],
          timestamp=request.headers["X-Yoshi-Timestamp"],
          secret=os.environ["WEBHOOK_SECRET"],
      )

      if not is_valid:
          return HttpResponse(status=400)

      event = json.loads(request.body)
      print(f"Received {event['type']}: {event['id']}")
      return HttpResponse(status=200)
  ```
</CodeGroup>

## Replay protection

The SDK rejects webhooks with timestamps older than 5 minutes by default. This prevents replay attacks where an attacker resends a captured webhook request.

You can customize this tolerance:

<CodeGroup>
  ```typescript TypeScript theme={null}
  verifySignature(payload, signature, timestamp, secret, {
    tolerance: 600, // 10 minutes
  });
  ```

  ```python Python theme={null}
  verify_signature(
      payload=payload,
      signature=signature,
      timestamp=timestamp,
      secret=secret,
      tolerance=600,  # 10 minutes
  )
  ```
</CodeGroup>

## Manual verification

If you prefer to verify signatures without the SDK:

<CodeGroup>
  ```typescript TypeScript / Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verifyWebhook(body: string, signature: string, timestamp: string, secret: string): boolean {
    // Check timestamp is within tolerance (5 minutes)
    const ts = parseInt(timestamp, 10);
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - ts) > 300) return false;

    // Compute expected signature
    const signedPayload = `${timestamp}.${body}`;
    const expected = createHmac("sha256", secret).update(signedPayload).digest("hex");

    // Constant-time comparison
    const sigBuf = Buffer.from(signature, "utf-8");
    const expectedBuf = Buffer.from(expected, "utf-8");
    if (sigBuf.length !== expectedBuf.length) return false;

    return timingSafeEqual(sigBuf, expectedBuf);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_webhook(body: str, signature: str, timestamp: str, secret: str) -> bool:
      # Check timestamp is within tolerance (5 minutes)
      ts = int(timestamp)
      now = int(time.time())
      if abs(now - ts) > 300:
          return False

      # Compute expected signature
      signed_payload = f"{timestamp}.{body}".encode("utf-8")
      expected = hmac.new(
          secret.encode("utf-8"),
          signed_payload,
          hashlib.sha256,
      ).hexdigest()

      # Constant-time comparison
      return hmac.compare_digest(signature, expected)
  ```

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

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"math"
  	"strconv"
  	"time"
  )

  func VerifyWebhook(body, signature, timestamp, secret string) bool {
  	// Check timestamp is within tolerance (5 minutes)
  	ts, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil {
  		return false
  	}
  	now := time.Now().Unix()
  	if math.Abs(float64(now-ts)) > 300 {
  		return false
  	}

  	// Compute expected signature
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(timestamp + "." + body))
  	expected := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(signature), []byte(expected))
  }
  ```
</CodeGroup>

<Warning>
  Always use constant-time comparison (`timingSafeEqual` in Node.js, `hmac.compare_digest` in Python, `hmac.Equal` in Go) to prevent timing attacks. Never use `===` or `==` for signature comparison.
</Warning>

## Secret rotation

When you [rotate your signing secret](/webhooks/management#rotate-signing-secret), there is a brief window where both the old and new secrets are valid. During this window, Yoshi signs webhooks with both secrets. Your verification logic should try the new secret first, then fall back to the old secret.

The SDK handles this automatically if you pass an array of secrets:

<CodeGroup>
  ```typescript TypeScript theme={null}
  const secrets = [process.env.WEBHOOK_SECRET_NEW, process.env.WEBHOOK_SECRET_OLD];

  const isValid = secrets.some((secret) =>
    verifySignature(req.body, req.headers["x-yoshi-signature"], req.headers["x-yoshi-timestamp"], secret)
  );
  ```

  ```python Python theme={null}
  secrets = [os.environ["WEBHOOK_SECRET_NEW"], os.environ["WEBHOOK_SECRET_OLD"]]

  is_valid = any(
      verify_signature(
          payload=request.body,
          signature=request.headers["x-yoshi-signature"],
          timestamp=request.headers["x-yoshi-timestamp"],
          secret=secret,
      )
      for secret in secrets
  )
  ```
</CodeGroup>
