Skip to main content
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:
1

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.
2

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.
3

Verify and handle the payload

Your endpoint verifies the X-Webhook-Signature header against your signing secret, then processes the JSON body.

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.
  • API key — Connection management uses a standard X-API-Key, bound to a project. Create one on the , 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 secretthis is the only time it is returned, so store it securely.
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"
  }'
Response (200)
{
  "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"
}
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 to get a new one.

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.
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.
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.

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:
curl -X POST "https://api.particle.pro/v1/alerts/$ALERT_ID/test-webhook" \
  -H "X-API-Key: $PARTICLE_API_KEY"
Particle POSTs 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. 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:
Response (200)
{
  "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_typeSent forBody shape
alert.match.createdA REALTIME alert, one POST per matchA single match, with full transcript windows
alert.digest.createdA DAILY or WEEKLY alert, one POST per digest windowAn array of matches, each summarized (no windows)
Both share the same envelope:
FieldTypeDescription
event_typestringalert.match.created or alert.digest.created.
delivery_idstringIdentifier for this delivery attempt. Mirrors the X-Webhook-ID header. See idempotency — this is not a stable key across retries.
timestampintegerUnix 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.
urlstringDeep link to the human-readable delivery page for this match or digest.
testbooleanPresent and true only on a test delivery; absent on real matches. Branch on it to exercise your parse-and-verify path while skipping real side effects.
alertobjectThe alert that fired: { "id", "title", "kind" }.
match / matchesobject / arrayThe match (realtime) or list of matches (digest).
The match object is the same shape the REST API returns from GET /v1/alerts/matches/{id}, 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; the examples below show the fields you’ll typically act on.

Realtime: a mention match

alert.match.created (ENTITY_MENTION)
{
  "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

alert.match.created (PODCAST_SPEAKER)
{
  "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} when you need them.
alert.digest.created
{
  "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.
HeaderPurpose
X-Webhook-IDIdentifier for this delivery attempt. Mirrors the body’s delivery_id.
X-Webhook-TimestampUnix seconds when the payload was signed (send time). Used in the signature and for replay protection.
X-Webhook-Signaturev1,<base64> — the HMAC-SHA256 signature.
The signature is computed over the string:
{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.
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.
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);
});

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.
Deduplicate on the match’s idmatch.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.

Manage connections

List a project's connections (secrets masked)
curl "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections" \
  -H "X-API-Key: $PARTICLE_API_KEY"
Get one connection
curl "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections/Wd9k2Lp7Qm3xZ" \
  -H "X-API-Key: $PARTICLE_API_KEY"
Rotate the signing secret (returns the new secret once)
curl -X POST "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections/Wd9k2Lp7Qm3xZ/secret/rotate" \
  -H "X-API-Key: $PARTICLE_API_KEY"
Delete a connection (soft delete; referencing alerts skip it)
curl -X DELETE "https://api.particle.pro/v1/projects/$PARTICLE_PROJECT_ID/webhooks/connections/Wd9k2Lp7Qm3xZ" \
  -H "X-API-Key: $PARTICLE_API_KEY"
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.
The full request and response schema for each endpoint is in the API reference:
Method & pathReference
POST /v1/projects/{projectId}/webhooks/connectionsCreate a webhook connection
GET /v1/projects/{projectId}/webhooks/connectionsList webhook connections for a project
GET /v1/projects/{projectId}/webhooks/connections/{id}Get a webhook connection
POST /v1/projects/{projectId}/webhooks/connections/{id}/secret/rotateRotate a webhook connection’s signing secret
DELETE /v1/projects/{projectId}/webhooks/connections/{id}Delete a webhook connection