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

# Webhooks — receive real-time alert notifications

> Configure Zespan to POST a signed JSON payload to your endpoint when an alert fires. Covers payload schema, HMAC signature verification, delivery guarantees, and integration examples.

Webhooks let Zespan push alert notifications to your own infrastructure in real time. When an alert rule fires, Zespan immediately sends a signed HTTP POST to your configured endpoint. Use webhooks to integrate with Slack, PagerDuty, OpsGenie, custom dashboards, or any HTTP endpoint.

<Note>
  This page covers **alert webhooks** — notifications for error rate, latency, and cost threshold breaches. Looking for **prompt webhooks** (`version.created`, `label.assigned`, `version.deleted`)? See [Prompts → Webhooks](/dashboard/prompts#webhooks).
</Note>

<Note>
  Webhooks are available on the **Scale** plan. On Pro and Team, email notifications are available but webhook delivery is not.
</Note>

## Configuring a webhook URL

Webhook URLs are set per alert rule. When creating or editing an alert rule in **Dashboard → Alerts**, enter your endpoint URL in the **Webhook URL** field. The URL must:

* Use HTTPS
* Accept POST requests
* Respond with a 2xx status code within 10 seconds

## Payload schema

When an alert fires, Zespan sends a POST request with `Content-Type: application/json` and the following body:

```json theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
{
  "event": "alert.fired",
  "alert_id": "alert_abc123",
  "rule_name": "Error rate above 5%",
  "metric": "error_rate",
  "condition": ">",
  "threshold": 0.05,
  "current_value": 0.12,
  "window_min": 15,
  "project_id": "proj_xyz789",
  "project_name": "acme-production",
  "organization_id": "org_def456",
  "organization_name": "AcmeCorp",
  "fired_at": "2026-04-20T14:32:00Z",
  "dashboard_url": "https://app.zespan.com/acme/acme-production/traces",
  "signature": "sha256=a4b3c2d1..."
}
```

<ResponseField name="event" type="string">
  Always `"alert.fired"` for alert webhooks.
</ResponseField>

<ResponseField name="alert_id" type="string">
  The ID of the alert rule that triggered.
</ResponseField>

<ResponseField name="rule_name" type="string">
  The human-readable name of the alert rule, as set in the dashboard.
</ResponseField>

<ResponseField name="metric" type="string">
  The metric that crossed the threshold: `error_rate`, `cost_usd`, `latency_ms`, `request_count`, or `token_count`.
</ResponseField>

<ResponseField name="condition" type="string">
  The comparison that triggered: `">"` or `"<"`.
</ResponseField>

<ResponseField name="threshold" type="number">
  The configured threshold value.
</ResponseField>

<ResponseField name="current_value" type="number">
  The actual metric value at the time of firing.
</ResponseField>

<ResponseField name="window_min" type="number">
  The aggregation window in minutes.
</ResponseField>

<ResponseField name="fired_at" type="string">
  ISO 8601 UTC timestamp when the alert fired.
</ResponseField>

<ResponseField name="dashboard_url" type="string">
  Direct link to the traces page for this project — include this in your Slack message for one-click investigation.
</ResponseField>

<ResponseField name="signature" type="string">
  HMAC-SHA256 signature for verifying the payload. See below.
</ResponseField>

***

## Verifying the signature

Every webhook payload includes a `signature` field in the format `sha256=<hex_digest>`. You should verify this before processing the payload to protect against spoofed requests.

The signature is computed as:

```
HMAC-SHA256(webhook_secret, raw_request_body)
```

Where `webhook_secret` is the signing secret shown once when you configure the webhook URL in the dashboard. Store it as an environment variable.

<Tabs>
  <Tab title="Node.js">
    ```typescript theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    import crypto from "crypto";
    import { Request, Response } from "express";

    const WEBHOOK_SECRET = process.env.ZESPAN_WEBHOOK_SECRET!;

    export function handleWebhook(req: Request, res: Response) {
      const rawBody = req.body; // must be the raw buffer, not parsed JSON
      const signature = req.headers["x-zespan-signature"] as string;

      const expected = "sha256=" + crypto
        .createHmac("sha256", WEBHOOK_SECRET)
        .update(rawBody)
        .digest("hex");

      if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
        return res.status(401).json({ error: "Invalid signature" });
      }

      const payload = JSON.parse(rawBody.toString());
      // Process payload...

      return res.status(200).json({ received: true });
    }
    ```

    <Note>
      Use the raw request body (Buffer) for signature verification, not the parsed JSON object. Parsing changes whitespace and field ordering, which invalidates the signature.
    </Note>
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    import hmac
    import hashlib
    import os
    from flask import Flask, request, abort

    app = Flask(__name__)
    WEBHOOK_SECRET = os.environ["ZESPAN_WEBHOOK_SECRET"]

    @app.post("/webhooks/zespan")
    def handle_webhook():
        raw_body = request.get_data()
        signature = request.headers.get("X-Zespan-Signature", "")

        expected = "sha256=" + hmac.new(
            WEBHOOK_SECRET.encode(),
            raw_body,
            hashlib.sha256,
        ).hexdigest()

        if not hmac.compare_digest(signature, expected):
            abort(401)

        payload = request.get_json(force=True)
        # Process payload...

        return {"received": True}
    ```
  </Tab>
</Tabs>

***

## Delivery guarantees

| Property      | Value                                               |
| ------------- | --------------------------------------------------- |
| Timeout       | 10 seconds per attempt                              |
| Retries       | 3 attempts with exponential backoff (5s, 25s, 125s) |
| Ordering      | Not guaranteed — events may arrive out of order     |
| Deduplication | Use `alert_id` + `fired_at` to deduplicate          |

If all 3 delivery attempts fail, the webhook is marked as failed and no further retries are made. You can see failed deliveries in **Dashboard → Alerts → Webhook history**.

***

## Integration examples

### Slack

Post a formatted message to a Slack channel using an incoming webhook:

```typescript theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
export async function POST(req: Request) {
  const rawBody = await req.arrayBuffer();
  // ... verify signature ...

  const alert = JSON.parse(Buffer.from(rawBody).toString());

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      text: `🚨 *Zespan Alert: ${alert.rule_name}*`,
      blocks: [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `*${alert.rule_name}*\n${alert.metric} is ${alert.current_value} (threshold: ${alert.threshold})`,
          },
        },
        {
          type: "actions",
          elements: [
            {
              type: "button",
              text: { type: "plain_text", text: "View traces" },
              url: alert.dashboard_url,
            },
          ],
        },
      ],
    }),
  });

  return Response.json({ ok: true });
}
```

### PagerDuty

Create a PagerDuty incident from an alert:

```typescript theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
await fetch("https://events.pagerduty.com/v2/enqueue", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Token token=${process.env.PAGERDUTY_API_KEY}`,
  },
  body: JSON.stringify({
    routing_key: process.env.PAGERDUTY_ROUTING_KEY,
    event_action: "trigger",
    payload: {
      summary: `Zespan: ${alert.rule_name}`,
      source: alert.project_name,
      severity: alert.current_value > alert.threshold * 2 ? "critical" : "warning",
      custom_details: {
        metric: alert.metric,
        current_value: alert.current_value,
        threshold: alert.threshold,
        window_min: alert.window_min,
        dashboard: alert.dashboard_url,
      },
    },
  }),
});
```

***

## Testing your webhook endpoint

Use the **Test webhook** button in the alert rule editor to send a sample payload to your endpoint. The test payload uses `current_value` equal to `threshold + 1` so your handler can distinguish it from real alerts if needed (the `fired_at` timestamp will also be clearly recent).

Alternatively, use a tool like [webhook.site](https://webhook.site) to inspect raw payloads during development before wiring up your real handler.
