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

# Verifying Signatures

> Confirm each webhook came from StockInsights and was not modified in transit.

## Why verify

Your webhook endpoint is a public URL, so anyone could `POST` to it. Every
StockInsights delivery is signed with a secret only you and StockInsights know.
Verifying the signature on each request proves it genuinely came from
StockInsights and that the body was not altered in transit.

<Warning>
  Verify the signature before you trust or act on any webhook payload.
</Warning>

## Request headers

Each delivery includes these headers:

| Header                      | Description                                                               |
| --------------------------- | ------------------------------------------------------------------------- |
| `X-StockInsights-Signature` | The signature to verify, in the form `t=<timestamp>,v1=<hex>`.            |
| `X-StockInsights-Event`     | The event type, e.g. `filing.created`.                                    |
| `X-StockInsights-Delivery`  | Unique id for this delivery attempt. Useful in logs and support requests. |
| `User-Agent`                | Always `stockinsights-webhooks/1.0`.                                      |

## How the signature works

The `X-StockInsights-Signature` header has two comma-separated fields:

```
X-StockInsights-Signature: t=1721468400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8e6
```

* `t` — the UNIX timestamp (seconds) when the signature was generated.
* `v1` — a hex-encoded **HMAC-SHA256** of the string `{t}.{raw_body}`, keyed by
  your signing secret.

The signed string is the timestamp, a literal `.`, then the **exact raw request
body**. To verify, recompute the HMAC and compare it to `v1` in constant time.

<Warning>
  Your signing secret is **hex-encoded**. Decode it to raw bytes before using it
  as the HMAC key. Compute the HMAC over the raw request body **exactly as
  received** — do not parse and re-serialize the JSON first, or the bytes (and the
  signature) will differ.
</Warning>

## Verify a signature

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

  function verifyWebhook(rawBody, signatureHeader, secretHex, toleranceSeconds = 300) {
    // Parse "t=...,v1=..."
    const fields = {};
    for (const part of signatureHeader.split(",")) {
      const i = part.indexOf("=");
      fields[part.slice(0, i)] = part.slice(i + 1);
    }
    const timestamp = Number(fields.t);
    const provided = fields.v1;
    if (!timestamp || !provided) return false;

    // Reject stale timestamps to blunt replay attacks.
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

    // The secret is hex-encoded; decode it to bytes for the HMAC key.
    const key = Buffer.from(secretHex, "hex");
    const expected = crypto
      .createHmac("sha256", key)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

    const a = Buffer.from(expected);
    const b = Buffer.from(provided);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

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


  def verify_webhook(raw_body: bytes, signature_header: str, secret_hex: str, tolerance_seconds: int = 300) -> bool:
      # Parse "t=...,v1=..."
      try:
          fields = dict(part.split("=", 1) for part in signature_header.split(","))
          timestamp = int(fields["t"])
          provided = fields["v1"]
      except (KeyError, ValueError):
          return False

      # Reject stale timestamps to blunt replay attacks.
      if abs(time.time() - timestamp) > tolerance_seconds:
          return False

      # The secret is hex-encoded; decode it to bytes for the HMAC key.
      key = bytes.fromhex(secret_hex)
      signed = f"{timestamp}.".encode() + raw_body  # raw body, exactly as received
      expected = hmac.new(key, signed, hashlib.sha256).hexdigest()

      return hmac.compare_digest(expected, provided)
  ```
</CodeGroup>

### Using it in a handler

<CodeGroup>
  ```javascript Express theme={null}
  const express = require("express");
  const app = express();

  // Capture the raw body — required for signature verification.
  app.use("/webhooks/stockinsights", express.raw({ type: "application/json" }));

  app.post("/webhooks/stockinsights", (req, res) => {
    const signature = req.get("X-StockInsights-Signature");
    const rawBody = req.body.toString("utf8");

    // 503, not 400: a signature mismatch is usually a secret that has not rolled
    // out yet, and only a 5xx gets retried. A 4xx drops the event permanently.
    if (!verifyWebhook(rawBody, signature, process.env.STOCKINSIGHTS_WEBHOOK_SECRET)) {
      return res.status(503).send("invalid signature");
    }

    const event = JSON.parse(rawBody);
    // Acknowledge fast; process asynchronously.
    res.sendStatus(200);

    handleEvent(event); // your logic
  });
  ```

  ```python Flask theme={null}
  from flask import Flask, request, abort

  app = Flask(__name__)


  @app.post("/webhooks/stockinsights")
  def stockinsights_webhook():
      signature = request.headers.get("X-StockInsights-Signature", "")
      raw_body = request.get_data()  # bytes, exactly as received

      # 503, not 400: a signature mismatch is usually a secret that has not rolled
      # out yet, and only a 5xx gets retried. A 4xx drops the event permanently.
      if not verify_webhook(raw_body, signature, WEBHOOK_SECRET):
          abort(503, "invalid signature")

      event = request.get_json()
      # Acknowledge fast; process asynchronously.
      handle_event(event)  # your logic
      return "", 200
  ```
</CodeGroup>

## Timestamp tolerance and replay

The timestamp in `t` lets you reject old requests. StockInsights signs with the
current time, and the examples above reject anything more than **300 seconds**
(5 minutes) from your server's clock. This limits how long a captured request
could be replayed against your endpoint.

Keep your server clock in sync (NTP) so legitimate requests are not rejected for
clock drift. If your endpoint sits behind a proxy that buffers requests, make
sure the tolerance comfortably exceeds any added delay.

## Rotating the secret

Rotating the signing secret (from **Account Settings → Webhooks**) issues a new
secret and **invalidates the previous one immediately**. There is no overlap
window in which both secrets verify, so plan the swap:

1. Rotate in the dashboard and copy the new secret.
2. Deploy it to your endpoint promptly.

Between those two steps, deliveries are signed with the new secret while your
endpoint still holds the old one, so they will fail verification.

<Warning>
  What happens to those deliveries depends entirely on the status code your
  handler returns. A `5xx` (as in the examples above) is
  [retried](/api-reference/webhooks/delivery-and-retries), so the events land once
  the new secret is deployed. A `4xx` is treated as a permanent failure and
  **those events are dropped**. If your handler returns `400` or `401` on an
  invalid signature, change it to a `5xx` before you rotate.
</Warning>

Rotation is only needed if the secret may have been exposed. You do not need to
rotate to recover a secret you did not save — it can be revealed again at any
time from the destination card in the dashboard.
