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

# Webhooks

> Receive monitoring alerts as signed HTTP POST requests: payload format, signature verification, and retry behavior.

Webhooks deliver monitoring alerts as HTTP `POST` requests to an endpoint you control, instead
of (or in addition to) email or Slack. This makes it easy to pipe alerts into a custom
dashboard, trigger downstream automation, or store them in your own database.

When a development matches one of your monitoring views, the matched event is serialized as
JSON and POSTed to your configured URL. Each request can be signed with an HMAC signature so
you can verify its authenticity.

## Setting up a webhook

Webhooks are configured in the [Intel app](https://app.blockworks.com), not through
the API: when you create or edit a monitoring view, its alert settings are where you enable
webhook delivery and set the URL and secret.

Webhook delivery is enabled per monitoring view by creating or updating an alert policy with a
`webhook_url`. Optionally include a `webhook_secret` to enable HMAC signature verification.

* `webhook_url` (required when `delivery_types` contains `webhook`): the HTTP(S) endpoint to POST to.
* `webhook_secret` (optional): a shared secret used to compute the HMAC signature. Set one so your endpoint can verify request authenticity. If omitted, the `X-Webhook-Signature` header is not included.

<Note>
  Webhooks are only delivered for the `immediately` cadence. A policy with `daily` or `weekly`
  cadence will not dispatch to webhook endpoints even if `webhook` is listed in
  `delivery_types` and a `webhook_url` is set.
</Note>

## Request format

Each webhook is delivered as an HTTP `POST` with the following headers:

| Header                | Value                                                                                                         |
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                            |
| `User-Agent`          | `MessariCNS/1.0`                                                                                              |
| `X-Webhook-Signature` | HMAC signature (only present when `webhook_secret` is set). See [Verifying signatures](#verifying-signatures) |

The request body is a JSON envelope. Optional fields that aren't set are **omitted entirely**
from the JSON rather than sent as `null`. The example below shows a fully populated payload:

```json theme={null}
{
  "id": "918be734-813f-4c00-91df-9bb5664a5120",
  "type": "monitoring_event",
  "timestamp": "2026-04-10T11:29:06.987283Z",
  "data": {
    "view": {
      "id": "f4fbb8a3-aa3f-4be3-9130-01a023f40b1e",
      "name": "Bitcoin Core Releases"
    },
    "event": {
      "id": "07aeb98e-b5cb-4756-ba18-44b6c0fd2f7f",
      "title": "Bitcoin Core 31.0 RC Release Testing"
    },
    "assets": [
      {
        "id": "1e31218a-e44e-4285-820c-8282ee222035",
        "name": "Bitcoin",
        "logo_url": "https://assets.example.com/bitcoin.png"
      }
    ],
    "latest_development": {
      "id": "456001d9-ae84-4371-b99e-36d050e96a53",
      "title": "Bitcoin Core Release Candidate Version 31.0rc1 Testing Available",
      "summary": "A new release candidate of Bitcoin Core, version 31.0rc1, is now available for testing...",
      "category": ["launches_and_releases"],
      "subcategory": ["software_release"],
      "importance": "medium",
      "actionable": false,
      "created_at": "2026-03-17T16:10:25Z",
      "started_at": "2026-03-17T16:10:25Z",
      "ended_at": "2026-03-17T16:10:25Z"
    }
  }
}
```

### Envelope fields

| Field       | Type              | Description                                                                                                                                                                |
| ----------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`        | string (UUID)     | Delivery event ID. The same `id` is reused across retries of a single delivery and across deliveries of the same event to multiple policies. Use it as an idempotency key. |
| `type`      | string            | The payload type. For monitoring alerts this is always `monitoring_event`.                                                                                                 |
| `timestamp` | string (RFC 3339) | When the event was created.                                                                                                                                                |
| `data`      | object            | The event payload. See below.                                                                                                                                              |

### Data fields

| Field                | Type   | Required | Description                                                                           |
| -------------------- | ------ | -------- | ------------------------------------------------------------------------------------- |
| `view`               | object | yes      | The monitoring view that matched (`id`, `name`).                                      |
| `event`              | object | yes      | The event the development belongs to (`id`, `title`).                                 |
| `assets`             | array  | optional | Assets extracted from the development (`id`, `name`, `logo_url`). Omitted when empty. |
| `latest_development` | object | yes      | The development that triggered the alert. See fields below.                           |

#### `latest_development` fields

| Field         | Type              | Required | Description                                                                           |
| ------------- | ----------------- | -------- | ------------------------------------------------------------------------------------- |
| `id`          | string (UUID)     | yes      | Development ID.                                                                       |
| `title`       | string            | yes      | Development title.                                                                    |
| `summary`     | string            | yes      | Development summary (Markdown).                                                       |
| `category`    | array of string   | optional | Category taxonomy tags. Omitted when empty.                                           |
| `subcategory` | array of string   | optional | Subcategory taxonomy tags. Omitted when empty.                                        |
| `importance`  | string            | optional | One of `low`, `medium`, `high`. Omitted when the development hasn't been classified.  |
| `actionable`  | bool              | yes      | Whether the development is marked actionable. Always present.                         |
| `created_at`  | string (RFC 3339) | yes      | When the development started. Same value as `started_at`, included for compatibility. |
| `started_at`  | string (RFC 3339) | yes      | When the development started.                                                         |
| `ended_at`    | string (RFC 3339) | optional | When the development ended. Omitted for ongoing developments.                         |

## Verifying signatures

When you provide a `webhook_secret`, every delivery includes an `X-Webhook-Signature` header.
Verify it on every incoming request to confirm the webhook is authentic and untampered.

### Header format

```
X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac>
```

* `t`: the Unix timestamp (seconds) at which the signature was generated
* `v1`: the HMAC-SHA256 signature as a hex string

### Computing the expected signature

The signed payload is the concatenation of the timestamp, a literal `.`, and the raw request body:

```
signed_payload = "{timestamp}.{raw_body}"
expected_sig   = HMAC_SHA256(webhook_secret, signed_payload)
```

Compare your computed `expected_sig` against the `v1` value from the header using a
constant-time comparison. If they match, the request is authentic.

### Rejecting stale signatures

A valid HMAC alone is not enough. Without a freshness check, an attacker who captures a
legitimate webhook could replay it indefinitely. Reject any request whose `t` timestamp is more
than a small tolerance away from your server's current time. A tolerance of **5 minutes**
comfortably covers network delay, clock skew, and the retry window described in
[Retries](#retries).

The helpers below combine both checks: they parse the header defensively (returning `false` on
any missing or malformed component), enforce the timestamp tolerance, and then compare the HMAC
in constant time.

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

  const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;

  function verifyWebhookSignature(rawBody, header, secret) {
    if (!header) return false;

    const parts = {};
    for (const part of header.split(",")) {
      const [k, v] = part.split("=");
      if (k && v) parts[k.trim()] = v.trim();
    }

    const timestamp = parts.t;
    const provided = parts.v1;
    if (!timestamp || !provided || !/^[a-f0-9]{64}$/i.test(provided)) {
      return false;
    }

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (!Number.isFinite(age) || age > SIGNATURE_TOLERANCE_SECONDS) {
      return false;
    }

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

    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(provided, "hex"),
    );
  }
  ```

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

  SIGNATURE_TOLERANCE_SECONDS = 5 * 60
  _HEX64 = re.compile(r"^[a-f0-9]{64}$", re.IGNORECASE)

  def verify_webhook_signature(raw_body: bytes, header: str, secret: str) -> bool:
      if not header:
          return False

      parts = {}
      for part in header.split(","):
          key, _, value = part.partition("=")
          if key and value:
              parts[key.strip()] = value.strip()

      timestamp = parts.get("t")
      provided = parts.get("v1")
      if not timestamp or not provided or not _HEX64.match(provided):
          return False

      try:
          age = abs(int(time.time()) - int(timestamp))
      except ValueError:
          return False
      if age > SIGNATURE_TOLERANCE_SECONDS:
          return False

      signed_payload = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(
          secret.encode(),
          signed_payload,
          hashlib.sha256,
      ).hexdigest()

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

<Tip>
  Always verify against the **raw** request body bytes. If your framework re-serializes the
  JSON before you receive it, the bytes will not match what was signed and verification will
  fail.
</Tip>

## Responding to webhooks

Your endpoint should return an HTTP `2xx` status code to acknowledge successful receipt. The
HTTP client has a **30-second** transport timeout, and each delivery attempt has a **1-minute**
overall timeout. Slow or non-`2xx` responses may trigger retries.

| Status code             | Treated as             |
| ----------------------- | ---------------------- |
| `2xx`                   | Success                |
| `429`                   | Retryable (rate limit) |
| `4xx` (other)           | Non-retryable failure  |
| `5xx`                   | Retryable failure      |
| network error / timeout | Retryable failure      |

Return `200 OK` quickly after durably queuing the alert for processing on your side, rather
than doing heavy work synchronously.

## Retries

When a delivery fails with a retryable status code, network error, or timeout, it is
automatically retried with exponential backoff:

* **Maximum attempts**: 5
* **Initial interval**: 1 second
* **Backoff coefficient**: 2x
* **Maximum interval**: 5 minutes

Non-retryable `4xx` responses (excluding `429`) are not retried. If your endpoint returns
`400 Bad Request`, the delivery is permanently marked as failed.

## Best practices

* **Always set a `webhook_secret`** and verify the `X-Webhook-Signature` header on every request, including the timestamp freshness check that prevents replay attacks.
* **Use HTTPS** for your webhook URL. Plain HTTP endpoints are accepted but strongly discouraged.
* **Make your handler idempotent.** Retries reuse the same envelope `id`; use it as an idempotency key to avoid double-processing the same event.
* **Respond quickly.** Return `2xx` as soon as you have durably queued the event; do downstream processing asynchronously.
* **Treat the body as authoritative.** Do not trust query strings or other unsigned inputs.

## Example: creating tickets in Linear

This walkthrough builds a webhook handler that receives a monitoring alert, verifies its
signature, acknowledges receipt, and creates a Linear issue in the background. The same
scaffolding works for any downstream destination: swap the Linear call for Slack, an on-call
tool, an internal API, or a queue.

The handler does five things in order:

<Steps>
  <Step title="Capture the raw body">
    Signatures are computed over raw bytes, not re-serialized JSON.
  </Step>

  <Step title="Verify the signature">
    Check the `X-Webhook-Signature` header before doing anything else.
  </Step>

  <Step title="Acknowledge immediately">
    Return `200 OK` so the sender isn't waiting on Linear's response.
  </Step>

  <Step title="Hand off to a background worker">
    Process the envelope asynchronously.
  </Step>

  <Step title="Map and post">
    Translate the alert into a Linear `issueCreate` mutation.
  </Step>
</Steps>

### The server

Instead of running a server, you can also use no-code tools such as
[Zapier](https://zapier.com/) or [n8n](https://n8n.io/) to receive and transform these requests.

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

  const SECRET = process.env.MONITORING_WEBHOOK_SECRET;

  // Capture the raw body so signature verification matches exactly what was signed
  app.post(
    "/webhooks/monitoring",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const signature = req.header("X-Webhook-Signature") ?? "";
      if (!verifyWebhookSignature(req.body, signature, SECRET)) {
        return res.status(401).send("invalid signature");
      }

      const envelope = JSON.parse(req.body.toString("utf8"));

      // Ack immediately, then process in the background
      res.status(200).send("ok");
      setImmediate(() => forwardToLinear(envelope).catch(console.error));
    }
  );

  app.listen(3000);
  ```

  ```python Python (Flask) theme={null}
  import os
  import threading
  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["MONITORING_WEBHOOK_SECRET"]

  @app.post("/webhooks/monitoring")
  def handle():
      raw_body = request.get_data()  # raw bytes, not request.json
      signature = request.headers.get("X-Webhook-Signature", "")
      if not verify_webhook_signature(raw_body, signature, SECRET):
          return "invalid signature", 401

      envelope = request.get_json()

      # Ack immediately, then process in the background
      threading.Thread(
          target=forward_to_linear, args=(envelope,), daemon=True
      ).start()
      return "ok", 200
  ```
</CodeGroup>

`verifyWebhookSignature` and `verify_webhook_signature` are the helpers from
[Verifying signatures](#verifying-signatures) above.

### Transforming and posting to Linear

Linear exposes a [GraphQL API](https://linear.app/developers/graphql#creating-and-editing-issues);
new issues are created with the `issueCreate` mutation. The transform takes a
`monitoring_event` envelope and produces an `IssueCreateInput`, mapping the development title
to the issue title, building a Markdown description from the view, asset list, and development
summary, and translating `importance` to Linear's priority enum.

Two pieces of config are needed: a Linear API key for the `Authorization` header, and the
target team ID.

<CodeGroup>
  ```javascript Node.js theme={null}
  const LINEAR_API_KEY = process.env.LINEAR_API_KEY;
  const LINEAR_TEAM_ID = process.env.LINEAR_TEAM_ID;

  // Linear priority enum: 0 none, 1 urgent, 2 high, 3 medium, 4 low
  const PRIORITY = { high: 2, medium: 3, low: 4 };

  async function forwardToLinear(envelope) {
    if (envelope.type !== "monitoring_event") return;

    const { view, latest_development: dev, assets = [] } = envelope.data;
    const assetList = assets.map((a) => a.name).join(", ") || "none";

    const input = {
      teamId: LINEAR_TEAM_ID,
      title: `[${view.name}] ${dev.title}`,
      description: [
        `**View:** ${view.name}`,
        `**Assets:** ${assetList}`,
        `**Detected:** ${dev.created_at}`,
        "",
        dev.summary,
      ].join("\n"),
      priority: PRIORITY[dev.importance] ?? 0,
    };

    const res = await fetch("https://api.linear.app/graphql", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: LINEAR_API_KEY,
      },
      body: JSON.stringify({
        query: `mutation($input: IssueCreateInput!) {
          issueCreate(input: $input) { success issue { identifier url } }
        }`,
        variables: { input },
      }),
    });

    if (!res.ok) throw new Error(`Linear ${res.status}: ${await res.text()}`);
  }
  ```

  ```python Python theme={null}
  import os
  import requests

  LINEAR_API_KEY = os.environ["LINEAR_API_KEY"]
  LINEAR_TEAM_ID = os.environ["LINEAR_TEAM_ID"]

  # Linear priority enum: 0 none, 1 urgent, 2 high, 3 medium, 4 low
  PRIORITY = {"high": 2, "medium": 3, "low": 4}

  def forward_to_linear(envelope):
      if envelope.get("type") != "monitoring_event":
          return

      data = envelope["data"]
      view = data["view"]
      dev = data["latest_development"]
      assets = data.get("assets", [])

      asset_list = ", ".join(a["name"] for a in assets) or "none"
      description = "\n".join([
          f"**View:** {view['name']}",
          f"**Assets:** {asset_list}",
          f"**Detected:** {dev['created_at']}",
          "",
          dev["summary"],
      ])

      input_ = {
          "teamId": LINEAR_TEAM_ID,
          "title": f"[{view['name']}] {dev['title']}",
          "description": description,
          "priority": PRIORITY.get(dev.get("importance"), 0),
      }

      res = requests.post(
          "https://api.linear.app/graphql",
          headers={
              "Content-Type": "application/json",
              "Authorization": LINEAR_API_KEY,
          },
          json={
              "query": """
                mutation($input: IssueCreateInput!) {
                  issueCreate(input: $input) { success issue { identifier url } }
                }
              """,
              "variables": {"input": input_},
          },
          timeout=10,
      )
      res.raise_for_status()
  ```
</CodeGroup>

<Note>
  Linear personal API keys are passed directly in the `Authorization` header (no `Bearer `
  prefix). OAuth access tokens use `Authorization: Bearer <token>`.
</Note>

<Tip>
  Because failed deliveries are retried, the same envelope can arrive more than once. To avoid
  duplicate Linear issues, dedupe on `envelope.id`: store seen IDs in a short-TTL cache, or
  include the ID in the issue description and search before creating.
</Tip>
