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

# Webhook delivery and signing

> Payloads Igris sends to your webhook, and how to verify their signatures.

## Delivery payloads

For `http` webhooks the body is the raw event:

```json theme={null}
{
  "type": "policy_deny",
  "teamId": "org_123",
  "serverId": "github-prod",
  "sessionId": "uuid",
  "toolName": "delete_repo",
  "policyRuleId": "optional",
  "reason": "Policy rule denied this tool call",
  "timestamp": "2026-01-01T00:00:00.000Z"
}
```

`type` is `policy_deny`, `anomaly`, or `session_suspended`. For `policy_deny` events from the LLM gateway, `toolName` holds the model name, `serverId` is the connection slug, and `sessionId` is an empty string. Slack and Discord webhooks receive a formatted message instead. Delivery is fire-and-forget: a failed delivery is not retried.

## Signing

`http` webhooks are signed. Slack and Discord webhooks are not, because they cannot verify custom headers.

Each delivery carries two headers:

| Header | Value |
| - | - |
| `X-Igris-Timestamp` | Unix time in seconds |
| `X-Igris-Signature` | `sha256=` followed by the hex HMAC-SHA256 of `"<timestamp>.<raw body>"` |

The HMAC key is the full secret string, **including the `whsec_` prefix**. The signed message is the timestamp, a dot, then the exact raw request body bytes.

The secret is shown once, when you create the webhook and when you rotate it. Store it then. In the dashboard, rotate from **Settings → Webhooks** with the **Rotate signing secret** row action.

<Warning>
  HTTP webhooks created before signing was added are unsigned until you rotate their secret. Rotation takes effect immediately, with no grace period. Update your receiver right after you rotate.
</Warning>

When you verify a delivery:

1. Compute the HMAC over the **raw** body, not a re-serialized JSON object.
2. Reject timestamps more than 5 minutes from your clock, to block replays.
3. Compare signatures with a constant-time function.

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

  export function verifyIgrisWebhook(
  	secret: string, // full secret, including the whsec_ prefix
  	rawBody: string, // exact bytes received, as a string
  	timestamp: string, // X-Igris-Timestamp header
  	signature: string, // X-Igris-Signature header
  ): boolean {
  	const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  	if (!Number.isFinite(age) || age > 300) return false;

  	const expected =
  		"sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");

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

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


  def verify_igris_webhook(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
      # secret: full secret including the whsec_ prefix
      # raw_body: exact bytes received, before any JSON parsing
      try:
          age = abs(time.time() - int(timestamp))
      except ValueError:
          return False
      if age > 300:
          return False

      message = timestamp.encode() + b"." + raw_body
      expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

## Rotate a secret

```bash theme={null}
curl -X POST https://api.igrisecurity.com/api/v1/webhooks/$WEBHOOK_ID/rotate-secret \
  -H "Authorization: Bearer $IGRIS_API_KEY"
```

The response is the webhook object plus a one-time `secret`. It returns `400` for Slack and Discord webhooks.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.