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

# How to Verify CloudTalk Webhook Signatures

> Every account-level CloudTalk webhook is signed with HMAC-SHA256. Learn how to verify the svix-id, svix-timestamp, and svix-signature headers with a library or manually.

Every webhook sent from **Account → Webhooks** is signed, so you can prove a request really came from CloudTalk and wasn't tampered with. Verify the signature before trusting any payload: an unverified webhook endpoint will accept requests from anyone who discovers its URL.

Each endpoint has its own **signing secret**. To see it, open the endpoint under **Account → Webhooks** in your Dashboard and reveal the secret on the endpoint's page. It looks like `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`.

This scheme covers the account-level webhooks only. Other CloudTalk features that call an endpoint you own (the Call Flow Designer **Webhook** step, an AI Voice Agent's **Send via Webhook** option, Workflow Automation actions) are not signed this way; authenticate those the way their own setup screen describes.

## Signature headers

Every delivery carries three headers:

| Header           | Meaning                                                                                 |
| ---------------- | --------------------------------------------------------------------------------------- |
| `svix-id`        | The delivery's unique message id: the same on every retry of the same event             |
| `svix-timestamp` | When the request was sent, as a Unix timestamp in seconds                               |
| `svix-signature` | One or more Base64 signatures, space-delimited, each prefixed with a version like `v1,` |

## Verify with a library

CloudTalk signatures follow the widely-used [Svix webhook signature scheme](https://docs.svix.com/receiving/verifying-payloads/how), so you can verify with the open-source Svix libraries: available for JavaScript, Python, PHP, Go, Ruby, Java, C#, and Rust.

<Warning>
  Always verify against the **raw request body**, exactly as received. Parsing the JSON and re-serializing it (even changing whitespace) breaks the signature.
</Warning>

<CodeGroup>
  ```javascript Node.js theme={"system"}
  import { Webhook } from "svix"; // npm install svix

  const secret = process.env.WEBHOOK_SECRET; // whsec_...

  app.post("/webhooks/cloudtalk", express.raw({ type: "application/json" }), async (req, res) => {
    const wh = new Webhook(secret);
    let event;
    try {
      // req.body must be the raw bytes, not parsed JSON
      event = wh.verify(req.body, {
        "svix-id": req.headers["svix-id"],
        "svix-timestamp": req.headers["svix-timestamp"],
        "svix-signature": req.headers["svix-signature"],
      });
    } catch (err) {
      return res.status(401).send("invalid signature");
    }

    // Store the event durably BEFORE acknowledging: once you return 2xx,
    // CloudTalk considers it delivered and will not retry on its own.
    try {
      await queue.enqueue(event);
    } catch (err) {
      return res.status(500).send("could not enqueue");
    }
    res.status(200).send("ok");
  });
  ```

  ```python Python theme={"system"}
  import os

  from svix.webhooks import Webhook, WebhookVerificationError  # pip install svix

  secret = os.environ["WEBHOOK_SECRET"]  # whsec_...

  @app.post("/webhooks/cloudtalk")
  async def handle_webhook(request: Request):
      raw_body = await request.body()
      try:
          wh = Webhook(secret)
          event = wh.verify(raw_body, dict(request.headers))
      except WebhookVerificationError:
          return Response(status_code=401)

      # Store the event durably BEFORE acknowledging: once you return 2xx,
      # CloudTalk considers it delivered and will not retry on its own.
      try:
          enqueue(event)
      except Exception:
          return Response(status_code=500)
      return Response(status_code=200)
  ```

  ```php PHP theme={"system"}
  <?php
  // composer require svix/svix

  $secret = getenv('WEBHOOK_SECRET'); // whsec_...
  $rawBody = file_get_contents('php://input');

  $headers = [
      'svix-id'        => $_SERVER['HTTP_SVIX_ID'] ?? '',
      'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '',
      'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '',
  ];

  try {
      $wh = new \Svix\Webhook($secret);
      $event = $wh->verify($rawBody, $headers);
  } catch (\Exception $e) {
      http_response_code(401);
      exit;
  }

  // Store the event durably BEFORE acknowledging: once you return 2xx,
  // CloudTalk considers it delivered and will not retry on its own.
  if (!enqueue($event)) {
      http_response_code(500);
      exit;
  }

  http_response_code(200);
  ```

  ```go Go theme={"system"}
  import svix "github.com/svix/svix-webhooks/go"

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

      wh, err := svix.NewWebhook(os.Getenv("WEBHOOK_SECRET")) // whsec_...
      if err != nil {
          http.Error(w, "config error", http.StatusInternalServerError)
          return
      }
      if err := wh.Verify(rawBody, r.Header); err != nil {
          http.Error(w, "invalid signature", http.StatusUnauthorized)
          return
      }

      // Store the event durably BEFORE acknowledging: once you return 2xx,
      // CloudTalk considers it delivered and will not retry on its own.
      if err := enqueue(rawBody); err != nil {
          http.Error(w, "could not enqueue", http.StatusInternalServerError)
          return
      }

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

## Verify manually

If you'd rather not add a dependency:

1. Build the signed content by joining three values with dots: `{svix-id}.{svix-timestamp}.{raw body}`.
2. Take the part of your signing secret after the `whsec_` prefix and **Base64-decode it**: that's your HMAC key.
3. Compute `HMAC-SHA256(key, signed_content)` and Base64-encode the result.
4. `svix-signature` can contain several space-delimited signatures (this enables zero-downtime secret rotation). Strip the `v1,` prefix from each and compare your computed signature against every one using a **constant-time comparison**. A single match means the webhook is authentic.
5. Reject requests whose `svix-timestamp` is more than 5 minutes from your server's time: this blocks replay attacks. Make sure your server clock is NTP-synced.

```javascript Manual verification (Node.js) theme={"system"}
const crypto = require("crypto");

function verify(secret, rawBody, headers) {
  const id = headers["svix-id"];
  const timestamp = headers["svix-timestamp"];
  const signatures = headers["svix-signature"];

  // Reject stale timestamps (5-minute tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.split("_")[1], "base64");
  const signedContent = `${id}.${timestamp}.${rawBody}`;
  const expected = crypto.createHmac("sha256", key).update(signedContent).digest("base64");

  return signatures.split(" ").some((versioned) => {
    const [, signature] = versioned.split(",");
    if (!signature) return false;
    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}
```

## Rotating your secret

If a signing secret leaks, open the endpoint under **Account → Webhooks** and click **Rotate secret**. During rotation, deliveries are signed with both the old and the new secret (that's why `svix-signature` can hold multiple signatures) so a correctly implemented verifier keeps working with zero downtime.


## Related topics

- [CloudTalk Webhooks](/guides/webhooks/overview.md)
- [CloudTalk Webhook Delivery, Retries, and Best Practices](/guides/webhooks/delivery.md)
- [How to Authenticate Requests to the CloudTalk REST API](/guides/authentication.md)
