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

# Webhook Security

> Verify that webhook requests are genuinely from Zenstep using HMAC-SHA256 signatures.

## Signature verification

Every webhook request from Zenstep includes an `X-Zenstep-Signature` header containing an HMAC-SHA256 signature of the raw request body. You should verify this signature before processing any webhook payload.

```
X-Zenstep-Signature: sha256=abc123def456...
```

***

## Getting your webhook secret

When you create a webhook endpoint in the dashboard (**Settings → Notifications → Add webhook**), Zenstep generates a unique secret for that endpoint. Copy the secret immediately — it is only shown once.

If you lose the secret, rotate it from the webhook settings page. All subsequent deliveries will use the new secret.

***

## Verifying the signature

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  function verifyZenstepSignature(payload, signature, secret) {
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");

    // Use timingSafeEqual to prevent timing attacks
    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  }

  // Express.js handler
  app.post(
    "/webhooks/zenstep",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const signature = req.headers["x-zenstep-signature"];
      const secret = process.env.ZENSTEP_WEBHOOK_SECRET;

      if (!verifyZenstepSignature(req.body, signature, secret)) {
        return res.status(401).send("Invalid signature");
      }

      const event = JSON.parse(req.body);
      // Process event...
      res.sendStatus(200);
    },
  );
  ```

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

  def verify_zenstep_signature(payload: bytes, signature: str, secret: str) -> bool:
      expected = "sha256=" + hmac.new(
          secret.encode("utf-8"),
          payload,
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(signature, expected)

  # FastAPI handler
  from fastapi import Request, HTTPException, Header

  @app.post("/webhooks/zenstep")
  async def handle_webhook(
      request: Request,
      x_zenstep_signature: str = Header(...)
  ):
      payload = await request.body()
      secret = os.environ["ZENSTEP_WEBHOOK_SECRET"]

      if not verify_zenstep_signature(payload, x_zenstep_signature, secret):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = await request.json()
      # Process event...
      return {"ok": True}
  ```

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

  import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/http"
    "os"
  )

  func verifySignature(payload []byte, signature string, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(expected))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Zenstep-Signature")
    secret := os.Getenv("ZENSTEP_WEBHOOK_SECRET")

    if !verifySignature(payload, signature, secret) {
      http.Error(w, "Invalid signature", http.StatusUnauthorized)
      return
    }

    // Process event...
    w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

***

## Security best practices

**Always verify signatures** — do not process webhook payloads without signature verification, even in development.

**Use the raw request body** — compute the HMAC over the raw bytes of the request body before JSON parsing. Whitespace differences will cause signature mismatches.

**Use timing-safe comparison** — always use `crypto.timingSafeEqual` (Node.js), `hmac.compare_digest` (Python), or `hmac.Equal` (Go) to compare the computed and received signatures. Standard string comparison is vulnerable to timing attacks.

**Store the secret in an environment variable** — never hardcode the webhook secret in your source code.

**Reject requests without the header** — if `X-Zenstep-Signature` is missing, return `401` immediately.

***

## Replay attacks

To prevent replay attacks, Zenstep includes a `timestamp` field in every payload. Reject payloads where the timestamp is more than 5 minutes in the past:

```javascript theme={null}
function isNotReplay(payload) {
  const timestamp = new Date(payload.timestamp);
  const ageMs = Date.now() - timestamp.getTime();
  return ageMs < 5 * 60 * 1000; // 5 minutes
}
```

Each delivery also has a unique `id` field. If your application requires strict idempotency, store processed delivery IDs and skip re-processing.
