---
title: "Webhooks"
description: "Get mentions and digests as signed requests to your own URL. Setup, retries, signature checks and every event."
canonical: https://docs.rumoro.dev/webhooks
markdown: https://docs.rumoro.dev/webhooks.mdx
---

# Webhooks

Get mentions and digests as signed requests to your own URL. Setup, retries, signature checks and every event.

A webhook is a [channel](/alerts) that sends to a URL you control. Every alert rule attached to it sends one signed JSON `POST` for each matching mention (instant rules) or for each digest (hourly, daily and weekly rules).

- One URL can serve many rules. Use the payload's `event` to tell them apart.
- A delivery tells you something happened. The mention itself is always at `GET /v1/mentions/{id}`.
- Reply with a 2xx right away and do the work afterwards. Rumoro ignores the response body.

## Setup

1. Create a webhook channel. The response contains the signing secret, which you only see once.
2. Add alert rules that send to it, each with its own filter and `event` name.
3. Rumoro sends a `POST` for every matching mention or digest. Reply with a 2xx within 10 seconds.

### Create the channel

```ts tab="TypeScript"
const { data: channel } = await rumoro.createChannel({
  body: { kind: 'webhook', url: 'https://hooks.example.dev/rumoro', label: 'Support bot', headers: { 'X-Team': 'support' } },
});
```

```python tab="Python"
channel = rumoro.channels.create(
    kind="webhook", url="https://hooks.example.dev/rumoro", label="Support bot", headers={"X-Team": "support"}
)
```

```bash tab="curl"
curl -X POST https://api.rumoro.dev/v1/channels \
  -H "Authorization: Bearer $RUMORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "webhook", "url": "https://hooks.example.dev/rumoro", "label": "Support bot",
        "headers": { "X-Team": "support" } }'
```

```json
{
  "id": "dest_...",
  "label": "Support bot",
  "stats": { "alerts": 0, "activeAlerts": 0, "lastDeliveryAt": null, "last7d": { "total": 0, "failed": 0 } },
  "createdAt": "2026-10-04T09:12:44.208Z",
  "kind": "webhook",
  "config": {
    "url": "https://hooks.example.dev/rumoro",
    "headers": { "x-team": "support" },
    "events": [],
    "secret": "whsec_..."
  }
}
```

You get `config.secret` only in this response and after a rotation. Without a `label`, the URL's host is used. The URL must be public and use `https`.

Rumoro sends your `headers` with every request. You can set up to 20. Names are letters, digits and hyphens (stored lowercase), and values are up to 1,024 printable ASCII characters. Names that would clash with Rumoro's own signing or transport headers are refused with `400`.

`PATCH /v1/channels/{id}` changes the URL, label or headers. Changing the URL or headers, or turning the channel off, cancels deliveries that haven't gone out yet.

### Attach a rule

```ts tab="TypeScript"
await rumoro.createAlert({
  body: { name: 'Bug reports', mode: 'instant', event: 'mention.bug_report', filter: { intents: ['bug_report'] }, channelIds: ['dest_...'] },
});
```

```python tab="Python"
rumoro.alerts.create(
    name="Bug reports", mode="instant", event="mention.bug_report", filter={"intents": ["bug_report"]}, channelIds=["dest_..."]
)
```

```bash tab="curl"
curl -X POST https://api.rumoro.dev/v1/alerts \
  -H "Authorization: Bearer $RUMORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Bug reports", "mode": "instant", "event": "mention.bug_report",
        "filter": { "intents": ["bug_report"] }, "channelIds": ["dest_..."] }'
```

`event` can be any lowercase name with dots, up to 60 characters. Instant rules default to `mention.matched` and digest rules to `digest`.

## Retries

When your URL doesn't reply with a 2xx within 10 seconds, Rumoro decides by the reason.

- **Temporary problems** are a timeout, a network error, `408`, `429` or a `5xx`. Instant deliveries are tried up to 5 times, about 30 seconds apart, or later if your `Retry-After` asks for it. Every attempt has the same `id`.
- **Permanent problems** are a redirect or any other `4xx`. Rumoro doesn't retry and records the status in the delivery log.
- **Digests** are retried per channel every 5 minutes for up to 6 hours. Hourly digests are retried until 45 minutes past the hour after they were due.
- A payload larger than 1 MiB is never sent.

## Duplicates

A retry has the same `id` as the first attempt, so keep the ids you have processed and skip repeats. A replay (`POST /v1/deliveries/{id}/replay`) comes with a new `id`.

Each delivery is one keyword match. A post that matches two of your keywords arrives twice, with two mention ids and two `keyword` objects. Mentions marked ignored or done, and posts by muted authors, are not sent. Matches scored as noise only reach rules whose `minRelevance` is below 40.

## Signature verification

Every request carries these headers.

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Mentions-Timestamp` | When Rumoro built the request, in Unix seconds |
| `X-Mentions-Signature-V2` | `v2=` and the hex HMAC-SHA256 of the timestamp, a dot and the raw body |
| `X-Mentions-Signature` | The hex HMAC-SHA256 of the raw body only. Kept for older code, without replay protection. |

### Verify a request

Use the whole secret as the HMAC key, including `whsec_`. Check the V2 signature against the raw body before you parse it, compare in constant time, and reject timestamps more than five minutes from now. Because the timestamp is part of the signature, a captured request can't be reused later. Every retry gets a new timestamp and signature.

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

// body: the raw request body as received. secret: your channel secret, including "whsec_".
export function isFromRumoro(body: Buffer, timestamp: string | undefined, signature: string | undefined, secret: string) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || !signature || !(age <= 300)) return false;
  const expected = Buffer.from('v2=' + createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex'));
  const given = Buffer.from(signature);
  return expected.length === given.length && timingSafeEqual(expected, given);
}
// isFromRumoro(rawBody, req.headers['x-mentions-timestamp'], req.headers['x-mentions-signature-v2'], secret)
```

```python
import hashlib
import hmac
import time

# body: the raw request body as bytes. secret: your channel secret, including "whsec_".
def is_from_rumoro(body: bytes, timestamp: str | None, signature: str | None, secret: str) -> bool:
    if not timestamp or not signature or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    digest = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("v2=" + digest, signature)
```

### Rotate the secret

`POST /v1/channels/{id}/rotate-secret` creates a new secret and shows it once. The old secret stops working at the same moment, so update your code right away.

## The payload

All events use the same envelope.

```json
{
  "id": "dlv_...",
  "event": "mention.bug_report",
  "createdAt": "2026-10-04T09:12:44.208Z",
  "alert": { "id": "feed_...", "name": "Bug reports" },
  "data": { }
}
```

| Field | Meaning |
| --- | --- |
| `id` | The delivery id. In the delivery log, scheduled daily and hourly digests are listed under the rule and period instead (`feed_…:2026-10-04`, `feed_…:h:2026-10-04T09`). |
| `event` | The rule's event name, an [account event](/webhooks/account-events), or `test` |
| `createdAt` | When Rumoro built the request |
| `alert` | The rule that sent it. `alert.id` is `null` for a channel test, and `alert` is `null` for account events. |
| `data` | A [mention](/webhooks/mention-events), a [digest](/webhooks/digest-events) or an [account event](/webhooks/account-events). For a test it is `{ "message" }`. |

## Events

### Rule events

The dashboard's **Webhooks** page offers these presets. Each one is a normal alert rule.

| Event | Mode | Filter |
| --- | --- | --- |
| `mention.matched` | instant | No filter, every relevant mention |
| `mention.high_relevance` | instant | `minRelevance: 80` |
| `mention.negative`, `mention.positive` | instant | `sentiments: ["negative"]` or `["positive"]` |
| `mention.buy_intent`, `mention.question`, `mention.complaint`, `mention.comparison`, `mention.churn_intent`, `mention.bug_report`, `mention.pricing` | instant | `intents` with that intent |
| `review.negative` | instant | `ratings: [1, 2]` |
| `digest` | daily | No filter, one summary a day |
| `test` | on request | Sent by the test calls below |

### Account events

[Account events](/webhooks/account-events) don't use rules. A channel subscribes to them by name, with `events` on `POST` or `PATCH /v1/channels`.

| Event | Sent when |
| --- | --- |
| `keyword.capped` | A keyword hit its monthly mention cap |
| `keyword.paused_for_balance` | A keyword stopped because the balance ran out |
| `keyword.resumed` | A keyword is collecting again |
| `wallet.low` | The balance dropped to 20% of the last credit |
| `wallet.paused` | All keywords stopped for lack of balance |
| `wallet.resumed` | A credit restarted them |
| `mention.spike` | A keyword got far more mentions than usual in the last hour |
| `sentiment.negative_spike` | A keyword's mentions turned negative over the last day |
| `keyword.noisy` | Most of a keyword's matches are noise |
| `channel.failing` | A channel's recent deliveries all failed |

The last four come from [Needs attention](/guides/attention).

## Testing and the delivery log

| Call | What it does |
| --- | --- |
| `POST /v1/channels/{id}/test` | Sends an `event: "test"` request to this channel and returns `{ "outcomes": [{ "channelId", "ok", "error" }] }` |
| `POST /v1/alerts/{id}/test` | Does the same for every channel of the rule, with `alert` set |
| `GET /v1/channels/{id}/deliveries` | Recent deliveries. Each has `kind` (`mention`, `digest` or `event`), `event`, `status` (`pending`, `delivered` or `failed`), `attempts`, the last `error`, `sentAt`, the rule's name and a short mention summary. Tests are not listed. |
| `POST /v1/deliveries/{id}/retry` | Sends a failed or held delivery again with the same `id` |
| `POST /v1/deliveries/{id}/replay` | Sends a finished delivery again with a new `id` |
| `DELETE /v1/channels/{id}` | Removes the channel from all rules and returns `204` |

Retry and replay need a body with a `requestId` (a UUID), such as `{ "requestId": "2f0b7c1e-4d93-4a8e-9b61-5c3e7a0d1f24" }`, and return `202`. They are not in the API reference. [Conventions](/conventions#outside-the-reference) explains how these endpoints answer.

With the CLI, use `rumoro channels:test dest_...` and `rumoro channels:deliveries dest_...`. The other calls are in the [API reference](/api/alerts/create-channel).
