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

# Deliver alerts to a webhook

> Receive alert matches as signed JSON POSTs: create a webhook connection, attach it to an alert, verify the signature, and handle the payload.

export const APIKeysLink = ({children}) => {
  return <a href="https://platform.particle.pro/tokens">{children}</a>;
};

A webhook delivers alert matches to your own endpoint as a signed JSON `POST`, instead of (or alongside) email. Use it to drive a Slack bot, open a ticket, write to a database, or trigger any workflow the moment a watched entity is mentioned or appears as a speaker.

Setting one up is three steps:

<Steps>
  <Step title="Create a webhook connection">
    A connection is a destination URL plus a generated signing secret, scoped to one project. You create it once and reuse it across alerts.
  </Step>

  <Step title="Attach the connection to an alert">
    Add a `WEBHOOK` notification channel that references the connection's ID. From then on, every match the alert produces is POSTed to the connection's URL.
  </Step>

  <Step title="Verify and handle the payload">
    Your endpoint verifies the `X-Webhook-Signature` header against your signing secret, then processes the JSON body.
  </Step>
</Steps>

## Before you start

* **Plan** — Webhooks are part of the alerts feature, so they require a **Team**, **Business**, or **Enterprise** plan, the same as [creating alerts](/alerts/create#before-you-start).
* **API key** — Connection management uses a standard `X-API-Key`, bound to a project. Create one on the <APIKeysLink>API keys page</APIKeysLink>, and pass that project's ID in the path.
* **A public HTTPS endpoint** — The destination URL must be `https` and must resolve to a public address. Private, loopback, and link-local addresses are rejected at create time and re-checked at delivery time.

## 1. Create a webhook connection

`POST` the destination URL to the project-scoped connections endpoint. The response includes the `secret` — **this is the only time it is returned**, so store it securely.

<CodeGroup>
  ```bash curl theme={"dark"}
  curl -X POST "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections" \
    -H "X-API-Key: $PARTICLE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://hooks.example.com/particle",
      "name": "Production alerts"
    }'
  ```

  ```js JavaScript theme={"dark"}
  const res = await fetch(
    `https://api.particle.pro/v1/projects/${process.env.PARTICLE_PROJECT_ID}/webhooks/connections`,
    {
      method: "POST",
      headers: {
        "X-API-Key": process.env.PARTICLE_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        url: "https://hooks.example.com/particle",
        name: "Production alerts",
      }),
    },
  );
  const connection = await res.json();
  ```
</CodeGroup>

```json Response (200) theme={"dark"}
{
  "id": "Wd9k2Lp7Qm3xZ",
  "project_id": "Vb3xN1aPq8",
  "surface": "PLATFORM",
  "name": "Production alerts",
  "url": "https://hooks.example.com/particle",
  "secret_prefix": "whsec_2f9a4c...",
  "secret": "whsec_2f9a4c7e8b1d6f30a5c2e9b8d4f17a6c3e0b9d8f1a2c4e6b8d0f2a4c6e8b0d2f",
  "created_at": "2026-06-16T15:10:00Z"
}
```

<Warning>
  The full `secret` is shown **once**, here. It can't be retrieved later — only its masked `secret_prefix` is returned on reads. If you lose it, [rotate the secret](#manage-connections) to get a new one.
</Warning>

## 2. Attach the connection to an alert

A webhook is just another notification channel. Add an entry to an alert's `notifications` array with `type: "WEBHOOK"` and the connection's `id` as `webhook_connection_id`. You can mix it with `EMAIL` channels on the same alert.

```bash theme={"dark"}
curl -X POST "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/alerts" \
  -H "X-API-Key: $PARTICLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Nvidia mentions on podcasts",
    "kind": "ENTITY_MENTION",
    "delivery_cadence": "REALTIME",
    "entities": [
      { "entity_type": "COMPANY", "entity_id": "3CensCwu5G2oKCFgPrNf89" }
    ],
    "notifications": [
      { "type": "WEBHOOK", "webhook_connection_id": "Wd9k2Lp7Qm3xZ" },
      { "type": "EMAIL", "email": "you@example.com" }
    ]
  }'
```

You can also add a webhook channel to an existing alert with `PATCH /v1/alerts/{id}`. Remember that `notifications`, when present in a `PATCH`, **replaces the whole set** — include every channel you want to keep. See [Create and manage alerts](/alerts/create#update-pause-and-delete).

<Note>
  The connection must already exist, and its `surface` must match the alert's. A connection created with an API key is `PLATFORM`; an alert created the same way can only reference a `PLATFORM` connection. Referencing an unknown or wrong-surface connection returns [`validation_error`](/errors/validation_error).
</Note>

## Send a test event

Rather than wait for a real match, trigger a synthetic delivery to check your endpoint end to end — that it receives the `POST`, verifies the signature, and parses the body:

```bash theme={"dark"}
curl -X POST "https://api.particle.pro/v1/alerts/$ALERT_ID/test-webhook" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

Particle `POST`s a real-shaped `alert.match.created` event to **every active** webhook connection on the alert, carrying the alert's real `id`, `title`, `kind`, and first watched entity, with illustrative sample match content. The only difference from a live delivery is the envelope's `test` field, set to `true` so your handler can tell it apart and skip real side effects. Nothing is persisted — a test never appears in the alert's [delivery log](/alerts/results). A channel whose connection has since been deleted is skipped and doesn't appear in the results.

The response reports the outcome for each connection:

```json Response (200) theme={"dark"}
{
  "deliveries": [
    {
      "webhook_connection_id": "Wd9k2Lp7Qm3xZ",
      "url": "https://hooks.example.com/particle",
      "delivered": true,
      "status_code": 200,
      "duration_ms": 142
    }
  ]
}
```

`delivered` is `true` when your endpoint answered `2xx`. A non-2xx or unreachable endpoint is reported as `delivered: false` with the reason in `error`, and the request **still returns `200`** — the send is what's under test, so your endpoint's own response comes back as data rather than an error. If the alert has no active webhook connection — either none is configured, or every configured connection has been deleted — the endpoint returns `422`.

## The payload

Particle POSTs `Content-Type: application/json` with a `User-Agent` of `ParticlePro-Webhooks/1.0`. Every payload carries an `event_type` so you can branch on the shape before parsing the rest.

| `event_type`           | Sent for                                                | Body shape                                            |
| ---------------------- | ------------------------------------------------------- | ----------------------------------------------------- |
| `alert.match.created`  | A `REALTIME` alert, one POST per match                  | A single `match`, with full transcript `windows`      |
| `alert.digest.created` | A `DAILY` or `WEEKLY` alert, one POST per digest window | An array of `matches`, each summarized (no `windows`) |

Both share the same envelope:

| Field               | Type           | Description                                                                                                                                                                                                       |
| ------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_type`        | string         | `alert.match.created` or `alert.digest.created`.                                                                                                                                                                  |
| `delivery_id`       | string         | Identifier for this delivery attempt. Mirrors the `X-Webhook-ID` header. See [idempotency](#idempotency) — this is **not** a stable key across retries.                                                           |
| `timestamp`         | integer        | Unix seconds for the event: the match's detection time (realtime), or the most recent match in the window (digest). Distinct from the `X-Webhook-Timestamp` header, which is the send time used in the signature. |
| `url`               | string         | Deep link to the human-readable delivery page for this match or digest.                                                                                                                                           |
| `test`              | boolean        | Present and `true` only on a [test delivery](#send-a-test-event); absent on real matches. Branch on it to exercise your parse-and-verify path while skipping real side effects.                                   |
| `alert`             | object         | The alert that fired: `{ "id", "title", "kind" }`.                                                                                                                                                                |
| `match` / `matches` | object / array | The match (realtime) or list of matches (digest).                                                                                                                                                                 |

The `match` object is the same shape the REST API returns from [`GET /v1/alerts/matches/{id}`](/alerts/results), minus a few REST-only fields: the `deliveries` array, the deprecated `monitor_id`, and the episode's raw `podcast_popularity` percentile (its human-readable `podcast_popularity_badge` is kept). The full field reference lives on [Alert results](/alerts/results); the examples below show the fields you'll typically act on.

### Realtime: a mention match

```json alert.match.created (ENTITY_MENTION) theme={"dark"}
{
  "event_type": "alert.match.created",
  "delivery_id": "5Hb2k9TfQ1mNpZ",
  "timestamp": 1718550000,
  "url": "https://platform.particle.pro/alerts/deliveries/5Hb2k9TfQ1mNpZ",
  "alert": {
    "id": "8Qw2mRk7Tf9LpZ",
    "title": "Nvidia mentions on podcasts",
    "kind": "ENTITY_MENTION"
  },
  "match": {
    "id": "Jx7c2QpR4nW1aD",
    "alert_id": "8Qw2mRk7Tf9LpZ",
    "kind": "ENTITY_MENTION",
    "entity_type": "COMPANY",
    "entity_id": "3CensCwu5G2oKCFgPrNf89",
    "source_type": "PODCAST_EPISODE",
    "source_id": "Qd8s1Lp93kFm2A",
    "detected_at": "2026-06-16T15:00:00Z",
    "mention_count": 4,
    "mention_variants": ["Nvidia", "NVDA"],
    "relevance": "on_target",
    "llm_summary": "The hosts break down Nvidia's data-center demand and what it means for the next earnings print.",
    "episode": {
      "title": "The AI chip supercycle",
      "slug": "the-ai-chip-supercycle",
      "published_at": "2026-06-16T12:00:00Z",
      "podcast_id": "P4n2Kd9sLx",
      "podcast_title": "Acquired",
      "podcast_slug": "acquired",
      "podcast_image_url": "https://cdn.particle.pro/podcasts/acquired.jpg",
      "podcast_popularity_badge": "Top Podcast"
    },
    "windows": [
      {
        "start_seconds": 1503.2,
        "end_seconds": 1548.9,
        "lines": [
          {
            "speaker": "Ben Gilbert",
            "start_seconds": 1503.2,
            "end_seconds": 1510.4,
            "text": "And that's why Nvidia's data-center revenue is the number everyone watches now.",
            "is_mention": true
          }
        ],
        "clip_url": "https://platform.particle.pro/podcasts/acquired/the-ai-chip-supercycle?t=1503-1549&q=Nvidia"
      }
    ]
  }
}
```

For an `ENTITY_MENTION` match, `mention_count` and `mention_variants` describe how the entity was referenced, and each window's `lines` carry the verbatim dialogue with the matched line flagged `is_mention`. `relevance` (`on_target` vs `incidental`) and `llm_summary` are added by the summarizer and may be absent if it hasn't finished or gave up.

### Realtime: a speaker appearance

```json alert.match.created (PODCAST_SPEAKER) theme={"dark"}
{
  "event_type": "alert.match.created",
  "delivery_id": "7Tn4p2WkR9bQ1d",
  "timestamp": 1718553600,
  "url": "https://platform.particle.pro/alerts/deliveries/7Tn4p2WkR9bQ1d",
  "alert": {
    "id": "2Lf9mQ4kTp7Zx",
    "title": "Sam Altman podcast appearances",
    "kind": "PODCAST_SPEAKER"
  },
  "match": {
    "id": "Kp3d9QmR2nX4aW",
    "alert_id": "2Lf9mQ4kTp7Zx",
    "kind": "PODCAST_SPEAKER",
    "entity_type": "KNOWLEDGE_GRAPH_ENTITY",
    "entity_id": "17PzxG1t12xzno",
    "source_type": "PODCAST_EPISODE",
    "source_id": "Rm5t2Lp84kQn9B",
    "detected_at": "2026-06-16T16:00:00Z",
    "mention_count": 37,
    "roles": ["GUEST"],
    "episode": {
      "title": "Building AGI, one model at a time",
      "podcast_id": "Lx9k2Pd4Sn",
      "podcast_title": "Lex Fridman Podcast",
      "podcast_image_url": "https://cdn.particle.pro/podcasts/lex-fridman.jpg"
    },
    "windows": [
      {
        "start_seconds": 122.0,
        "end_seconds": 168.5,
        "clip_id": "Cn8s2Qp4Rk",
        "clip_title": "On the pace of progress",
        "clip_type": "HIGHLIGHT",
        "lines": [
          {
            "speaker": "Sam Altman",
            "role": "GUEST",
            "start_seconds": 122.0,
            "end_seconds": 131.7,
            "text": "I think the rate of progress over the next two years will surprise even us."
          }
        ],
        "clip_url": "https://platform.particle.pro/podcasts/lex-fridman-podcast/building-agi?t=122-168&q=Sam%20Altman"
      }
    ]
  }
}
```

For a `PODCAST_SPEAKER` match, `roles` lists how the entity appeared (`GUEST`, `PANELIST`, `CORRESPONDENT`, `AUDIENCE`, or `SOUNDBITE_SPEAKER`), `mention_count` is the speaker's line count, and windows may carry clip metadata (`clip_id`, `clip_title`, …) when the episode has a clip for that speaker.

### Digest

A `DAILY` or `WEEKLY` alert sends one POST per window with all of its matches bundled into a `matches` array. To keep large digests bounded, each match is summarized — transcript `windows` are stripped, and a single `clip_url` per match points into its primary window's audio. Fetch full windows with [`GET /v1/alerts/matches/{id}`](/alerts/results) when you need them.

```json alert.digest.created theme={"dark"}
{
  "event_type": "alert.digest.created",
  "delivery_id": "9Qm2Lp7Tk4Rx1",
  "timestamp": 1718636400,
  "url": "https://platform.particle.pro/alerts/deliveries/9Qm2Lp7Tk4Rx1",
  "alert": {
    "id": "8Qw2mRk7Tf9LpZ",
    "title": "Nvidia mentions on podcasts",
    "kind": "ENTITY_MENTION"
  },
  "matches": [
    {
      "id": "Jx7c2QpR4nW1aD",
      "alert_id": "8Qw2mRk7Tf9LpZ",
      "kind": "ENTITY_MENTION",
      "entity_type": "COMPANY",
      "entity_id": "3CensCwu5G2oKCFgPrNf89",
      "source_type": "PODCAST_EPISODE",
      "source_id": "Qd8s1Lp93kFm2A",
      "detected_at": "2026-06-16T15:00:00Z",
      "mention_count": 4,
      "relevance": "on_target",
      "llm_summary": "The hosts break down Nvidia's data-center demand outlook.",
      "episode": {
        "title": "The AI chip supercycle",
        "podcast_title": "Acquired"
      },
      "clip_url": "https://platform.particle.pro/podcasts/acquired/the-ai-chip-supercycle?t=1503-1549&q=Nvidia"
    }
  ]
}
```

## Verify the signature

Every POST carries three headers. Verify the signature on each delivery before trusting the body — it proves the request came from Particle and was not modified.

| Header                | Purpose                                                                                                |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| `X-Webhook-ID`        | Identifier for this delivery attempt. Mirrors the body's `delivery_id`.                                |
| `X-Webhook-Timestamp` | Unix seconds when the payload was signed (send time). Used in the signature and for replay protection. |
| `X-Webhook-Signature` | `v1,<base64>` — the HMAC-SHA256 signature.                                                             |

The signature is computed over the string:

```text theme={"dark"}
{X-Webhook-ID}.{X-Webhook-Timestamp}.{raw_request_body}
```

To verify: recompute `"v1," + base64(HMAC-SHA256(signing_secret, that_string))` and compare it to `X-Webhook-Signature` with a constant-time comparison.

<Warning>
  Verify over the **raw request body bytes**, before any JSON parse-and-reserialize — re-encoding can reorder keys or change whitespace and break the signature. Also use the `X-Webhook-Timestamp` **header** value in the signed string, not the `timestamp` field inside the body; they are different values.
</Warning>

<CodeGroup>
  ```js Node.js (Express) theme={"dark"}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.PARTICLE_WEBHOOK_SECRET;

  // express.raw keeps req.body as the exact bytes that were signed.
  app.post("/particle", express.raw({ type: "application/json" }), (req, res) => {
    const id = req.get("X-Webhook-ID");
    const timestamp = req.get("X-Webhook-Timestamp");
    const signature = req.get("X-Webhook-Signature") ?? "";

    const signed = `${id}.${timestamp}.${req.body}`; // req.body is a Buffer
    const expected =
      "v1," + crypto.createHmac("sha256", SECRET).update(signed).digest("base64");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    // Reject stale deliveries (replay protection).
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(401).send("stale timestamp");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // ... handle event.event_type ...
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={"dark"}
  import base64, hashlib, hmac, os, time
  from flask import Flask, request, abort

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

  @app.post("/particle")
  def particle():
      raw = request.get_data()  # exact bytes that were signed
      delivery_id = request.headers.get("X-Webhook-ID", "")
      timestamp = request.headers.get("X-Webhook-Timestamp", "")
      signature = request.headers.get("X-Webhook-Signature", "")

      signed = f"{delivery_id}.{timestamp}.".encode() + raw
      digest = hmac.new(SECRET, signed, hashlib.sha256).digest()
      expected = "v1," + base64.b64encode(digest).decode()

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

      # Reject stale deliveries (replay protection).
      if abs(time.time() - int(timestamp)) > 300:
          abort(401)

      event = request.get_json()
      # ... handle event["event_type"] ...
      return "", 200
  ```

  ```bash Shell (openssl) theme={"dark"}
  # Given the captured headers and the raw body in body.json:
  SIGNED="${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.$(cat body.json)"
  EXPECTED="v1,$(printf '%s' "$SIGNED" \
    | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
    | base64)"

  [ "$EXPECTED" = "$WEBHOOK_SIGNATURE" ] && echo "ok" || echo "mismatch"
  ```
</CodeGroup>

## Delivery, retries, and idempotency

* **Respond quickly with a 2xx.** Any `2xx` status counts as success. Each POST is given up to 30 seconds end to end; do slow work asynchronously and acknowledge first.
* **Failures are retried (realtime).** A realtime delivery that fails — a non-2xx response, a timeout, or a connection error — is retried with a bounded budget of up to 5 attempts. After that the delivery is marked `ABANDONED` and dropped for that connection.
* **Redirects are not followed,** and a destination that resolves to a non-public address is refused at delivery time. Keep the endpoint on a stable public HTTPS URL.
* **A deleted connection is skipped** silently at delivery time; the alert's other channels still fire.

### Idempotency

Deliver-at-least-once means you can receive the same match more than once, so make your handler idempotent.

<Warning>
  Deduplicate on the match's **`id`** — `match.id` for a realtime `alert.match.created`, or each entry's `id` inside the `matches` array for an `alert.digest.created` — not on `delivery_id` / `X-Webhook-ID`. A retried delivery is signed fresh and carries a **new** `delivery_id` each attempt, so it is only a best-effort dedup hint, while a match's `id` is stable.
</Warning>

## Manage connections

```bash List a project's connections (secrets masked) theme={"dark"}
curl "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

```bash Get one connection theme={"dark"}
curl "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections/Wd9k2Lp7Qm3xZ" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

```bash Rotate the signing secret (returns the new secret once) theme={"dark"}
curl -X POST "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections/Wd9k2Lp7Qm3xZ/secret/rotate" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

```bash Delete a connection (soft delete; referencing alerts skip it) theme={"dark"}
curl -X DELETE "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections/Wd9k2Lp7Qm3xZ" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

<Warning>
  Rotating immediately invalidates the previous secret. Deploy the new secret to your endpoint before (or right as) you rotate, or in-flight deliveries will fail signature verification.
</Warning>

The full request and response schema for each endpoint is in the API reference:

| Method & path                                                           | Reference                                                                                                                      |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `POST /v1/projects/{projectId}/webhooks/connections`                    | [Create a webhook connection](/api-reference/webhook-connections/create-a-webhook-connection)                                  |
| `GET /v1/projects/{projectId}/webhooks/connections`                     | [List webhook connections for a project](/api-reference/webhook-connections/list-webhook-connections-for-a-project)            |
| `GET /v1/projects/{projectId}/webhooks/connections/{id}`                | [Get a webhook connection](/api-reference/webhook-connections/get-a-webhook-connection)                                        |
| `POST /v1/projects/{projectId}/webhooks/connections/{id}/secret/rotate` | [Rotate a webhook connection's signing secret](/api-reference/webhook-connections/rotate-a-webhook-connections-signing-secret) |
| `DELETE /v1/projects/{projectId}/webhooks/connections/{id}`             | [Delete a webhook connection](/api-reference/webhook-connections/delete-a-webhook-connection)                                  |

## Related

* [Create and manage alerts](/alerts/create) — create an alert and attach notification channels
* [Alerts overview](/alerts/overview) — alert kinds, delivery cadence, and the match model
* [Alert results](/alerts/results) — the full match field reference, also embedded in each payload
