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

# Webhooks

## **Webhooks Overview**

**Webhooks** allow you to receive 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 integrate monitoring alerts directly into your own systems — pipe them into a custom dashboard, trigger downstream automation, or store them in your own database.

When a development matches one of your monitoring views, Messari serializes the matched event as JSON and POSTs it to your configured URL. Each request can be signed with an HMAC signature so you can verify it came from Messari.

This page covers how to configure webhook delivery, the payload format, signature verification, and retry behavior.

***

## **Setting Up a Webhook**

To enable webhook delivery for a monitoring view, create or update an alert policy through the UI and provide a `webhook_url`. Optionally include a `webhook_secret` to enable HMAC signature verification.

### **Fields**

* `webhook_url` (required when `delivery_types` contains `webhook`) — the HTTP(S) endpoint Messari will POST to.
* `webhook_secret` (optional) — a shared secret used to compute the HMAC signature. We strongly recommend setting one so your endpoint can verify request authenticity. If omitted, the `X-Webhook-Signature` header will not be 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, not present 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://messari.io/asset-images/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)     | The CNS event ID. The same `id` is used across retries of a single delivery, and is shared across deliveries of the same event to multiple policies. Use it as an idempotency key on your side. |
| `type`      | string            | The payload type. For monitoring alerts this is always `monitoring_event`.                                                                                                                      |
| `timestamp` | string (RFC 3339) | When the event was created in Messari.                                                                                                                                                          |
| `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 underlying Messari 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 webhook delivery includes an `X-Webhook-Signature` header. Verify this signature on every incoming request to confirm the webhook came from Messari and was not tampered with.

### **Header Format**

The signature header uses a Stripe-style 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. We recommend a tolerance of **5 minutes**, which 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 verifyMessariSignature(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_messari_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 (see below).

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

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

***

## **Retries**

When a webhook delivery fails with a retryable status code, network error, or timeout, Messari automatically retries with exponential backoff:

* **Maximum attempts**: 5
* **Initial interval**: 1 second
* **Backoff coefficient**: 2×
* **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 to prevent 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 on your side 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 on Linear**

This walkthrough builds a complete webhook handler that receives a Messari 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, PagerDuty, an internal API, or a queue.

The handler does five things in order:

1. Captures the **raw** request body (signatures are computed over raw bytes, not re-serialized JSON).
2. Verifies the `X-Webhook-Signature` header.
3. Returns `200 OK` immediately so Messari isn't waiting on Linear's response.
4. Hands the envelope to a background worker.
5. Maps the alert into a Linear `issueCreate` mutation and posts it.

### **The Server**

Instead of running a server, you can also use no-code software like [Zapier](https://zapier.com/) or [n8n](https://n8n.io/) to handle and transform Messari's webhook requests.

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

  const SECRET = process.env.MESSARI_WEBHOOK_SECRET;

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

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

      // Ack immediately — 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["MESSARI_WEBHOOK_SECRET"]

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

      envelope = request.get_json()

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

`verifyMessariSignature` and `verify_messari_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 Messari `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 Messari's `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(", ") || "—";

    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 "—"
      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 Messari retries failed deliveries, 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. See [Best Practices](#best-practices).
</Tip>
