---
title: "Webhooks"
description: "How deliveries work: the flow, retries, idempotency, signature verification, and the events you can receive."
canonical: https://docs.mentio.dev/webhooks
markdown: https://docs.mentio.dev/webhooks.mdx
---

# Webhooks

How deliveries work: the flow, retries, idempotency, signature verification, and the events you can receive.

A webhook is a [channel](/alerts) that is a URL you own. Every alert rule that sends to it produces one signed JSON `POST` per matching mention (instant rules) or per digest (daily rules). One endpoint can serve any number of rules; each rule's `event` name rides in the payload so you can branch on it.

## How to think about webhooks

* One endpoint, many rules. Subscribe by attaching rules, and switch on `event`.
* A delivery is a notification, not the source of truth. The mention itself is always at `GET /v1/mentions/{id}` if you need it later or missed a delivery.
* Deduplicate on the payload `id`. A retry resends the same id.
* Verify `X-Mentions-Signature` over the raw body before you parse it.
* Answer quickly with a 2xx and do the real work asynchronously.

## Delivery flow

1. Create a webhook channel. The response carries the signing secret, shown only this once.
2. Attach one or more alert rules to it, each with a filter and an `event` name.
3. Receive a `POST` from Mentio for every mention that passes a rule, or for every digest a daily rule sends.
4. Return any 2xx within 10 seconds. The response body is ignored.
5. Use the test call to see a request land, and the delivery log to see what was sent and how it went.

```bash
# 1. The channel
curl -X POST "$MENTIONS_API_URL/v1/channels" \
  -H "Authorization: Bearer $MENTIONS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "webhook", "url": "https://example.com/hooks/mentio", "label": "Production",
        "headers": { "Authorization": "Bearer my-own-token" } }'
```

```json
{
  "id": "dest_...",
  "kind": "webhook",
  "label": "Production",
  "config": {
    "url": "https://example.com/hooks/mentio",
    "headers": { "Authorization": "Bearer my-own-token" },
    "secret": "whsec_..."
  },
  "stats": { "alerts": 0, "activeAlerts": 0, "lastDeliveryAt": null, "last7d": { "total": 0, "failed": 0 } },
  "createdAt": "2026-09-03T10:04:44.881Z"
}
```

`config.secret` is returned only here and after a rotation. `headers` are sent verbatim on every request (a token for your own endpoint, say); Mentio's own headers win on a name clash. Change the URL, label or headers later with `PATCH /v1/channels/{id}`.

```bash
# 2. A rule that sends to it
curl -X POST "$MENTIONS_API_URL/v1/alerts" \
  -H "Authorization: Bearer $MENTIONS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Negative mentions", "mode": "instant", "event": "mention.negative",
        "filter": { "sentiments": ["negative"] }, "channelIds": ["dest_..."] }'
```

`event` is any lowercase name of up to 60 characters, in one or more dotted segments (`digest`, `mention.negative`). Leave it out and an instant rule sends `mention.matched`, a daily rule `digest`.

## Delivery retries

A delivery succeeds when your endpoint returns a 2xx within 10 seconds. Otherwise:

* A timeout, a network error, `408`, `429` or any `5xx` is transient. An instant delivery is retried up to 5 attempts, about 30 seconds apart, with the same `id`.
* Any other `4xx` is permanent. No retry; the failure and its status are recorded in the delivery log.
* A digest is retried on later scheduler ticks for up to 6 hours while none of the rule's channels has accepted it.

## Idempotency

Every payload has an `id`, the delivery id. Retries resend the same `id`, so keep the ids you have handled and drop repeats. Webhooks are per keyword match: a post that matches two of your keywords arrives twice, with two mention ids and two `keyword` objects, so a consumer can key on the term. Ignored and done mentions and muted authors are never delivered.

## Signature verification

Every request carries `X-Mentions-Signature`: the lowercase hex HMAC-SHA256 of the raw request body, keyed with the channel secret. Compute it over the exact bytes you received, before parsing, and compare in constant time.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyMentioSignature(rawBody: Buffer, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

```python
import hashlib
import hmac

def verify_mentio_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

Rotate a secret with `POST /v1/channels/{id}/rotate-secret`. The new secret is shown once and the old one stops verifying at once, so deploy the new one right away.

## Available events

Every payload, whatever the event, has the same five fields:

```json
{
  "id": "dlv_...",
  "event": "mention.negative",
  "createdAt": "2026-09-03T10:04:44.881Z",
  "alert": { "id": "feed_...", "name": "Negative mentions" },
  "data": { ... }
}
```

| Field       | Meaning                                                                                                                        |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id`        | The delivery id, also the id in the channel's delivery log                                                                     |
| `event`     | The rule's event name, or `test`                                                                                               |
| `createdAt` | When the request was built                                                                                                     |
| `alert`     | The rule that produced it. `id` is `null` for a test sent from the dashboard's Webhooks page                                   |
| `data`      | The resource: a [mention](/webhooks/mention-events), a [digest](/webhooks/digest-events), or `{ "message": "..." }` for a test |

The event name is yours to choose per rule. The dashboard's Webhooks page offers these presets, each an ordinary rule with a filter:

| Event                    | Mode      | Filter                       | Payload                                    |
| ------------------------ | --------- | ---------------------------- | ------------------------------------------ |
| `mention.matched`        | instant   | None: every relevant mention | [Mention events](/webhooks/mention-events) |
| `mention.high_relevance` | instant   | `minRelevance: 80`           | [Mention events](/webhooks/mention-events) |
| `mention.negative`       | instant   | `sentiments: ["negative"]`   | [Mention events](/webhooks/mention-events) |
| `mention.positive`       | instant   | `sentiments: ["positive"]`   | [Mention events](/webhooks/mention-events) |
| `mention.buy_intent`     | instant   | `intents: ["buy_intent"]`    | [Mention events](/webhooks/mention-events) |
| `mention.question`       | instant   | `intents: ["question"]`      | [Mention events](/webhooks/mention-events) |
| `mention.complaint`      | instant   | `intents: ["complaint"]`     | [Mention events](/webhooks/mention-events) |
| `mention.comparison`     | instant   | `intents: ["comparison"]`    | [Mention events](/webhooks/mention-events) |
| `digest`                 | daily     | None: one summary a day      | [Digest events](/webhooks/digest-events)   |
| `test`                   | on demand | The test calls below         | `{ "message": "..." }`                     |

## Testing

| Call                               | What it does                                                                                                                                                                             |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/channels/{id}/test`      | Sends `event: "test"` with `data.message` to this channel; returns `{ "outcomes": [{ "channelId", "ok", "error" }] }`                                                                    |
| `POST /v1/alerts/{id}/test`        | The same through every channel of a rule, with `alert` filled in                                                                                                                         |
| `GET /v1/channels/{id}/deliveries` | The recent deliveries: `kind` (`mention` or `digest`), `status` (`pending`, `delivered`, `failed`), `attempts`, the last `error`, `sentAt`, the alert's name and a short mention summary |
| `DELETE /v1/channels/{id}`         | Removes the channel from every rule; `204`                                                                                                                                               |

From the command line: `mentio channels:test dest_...` and `mentio channels:deliveries dest_...`. Full schemas are in the [Alerts reference](/api/alerts/create-channel).
