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

# Outbound webhooks

> Get a signed HTTPS POST each time an answer is classified or a verdict is overridden.

Rumorly can POST each new verdict to your own endpoint: a Zapier or Make hook, your warehouse loader, or your backend. Every request is signed, so you can check it came from Rumorly.

Outbound webhooks are part of the Growth plan and higher, including the 14-day signup trial. A workspace can have up to 5 endpoints.

> [!NOTE]
>
> These are webhooks Rumorly **sends**. To send answers **to** Rumorly from a form tool, see [Generic webhooks](/collect/webhooks.md).

## Add an endpoint

In **Settings → Webhooks**, add an `https://` URL and choose the events. The signing secret (`whsec_…`) is shown once. Store it with your other secrets. If it leaks, rotate it: the new secret applies from the next attempt.

Only workspace owners and admins can manage endpoints, and only after confirming their email address, since every event carries respondents' answers and emails.

Use **Send test event** to send a sample. Its result, like every delivery, shows in the endpoint's delivery log.

## Events

| Event | Sent when |
| - | - |
| `response.classified` | The classifier files a verdict on an answer. This includes a reclassify, and approving or rejecting a proposed entity, which changes the answers that named it. The verdict may still be unsure: check `data.response.status` (`classified` or `needs_review`). |
| `response.overridden` | A member replaces the verdict by hand, in the dashboard or with `PATCH /v1/responses/{id}`. |

## What you receive

```http theme={null}
POST /rumorly HTTP/1.1
Content-Type: application/json
User-Agent: Rumorly-Webhooks/1.0 (+https://rumorly.io/docs)
Rumorly-Event: response.classified
Rumorly-Delivery: 5b0f8c52-3c1e-4c3a-9d55-2f7a0c1e9b41
Rumorly-Signature: t=1792656000,v1=6f1c3b…
```

```json theme={null}
{
  "id": "5b0f8c52-3c1e-4c3a-9d55-2f7a0c1e9b41",
  "type": "response.classified",
  "created_at": "2026-10-22T08:00:00.000Z",
  "test": false,
  "data": {
    "response": {
      "id": "8d7e0c0b-6a8f-4f0e-bb1a-0d9f2c3e4a51",
      "answer": "My cofounder heard Jake on Lenny's Podcast",
      "question": "How did you hear about us?",
      "source": "widget",
      "status": "classified",
      "answered_at": "2026-10-22T07:59:41.000Z",
      "created_at": "2026-10-22T07:59:41.000Z",
      "metadata": { "utm_source": "google" },
      "person": {
        "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "email": "ada@example.com",
        "external_id": "user_123",
        "name": "Ada Lovelace",
        "domain": "example.com"
      },
      "classification": {
        "channel": "podcast",
        "channel_label": "Podcast",
        "entity": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Lenny's Podcast", "kind": "podcast" },
        "proposed_entity": null,
        "entity_is_new": false,
        "confidence": 0.92,
        "method": "llm",
        "reasoning": "Names a podcast episode as the first touch.",
        "overridden": false,
        "classified_at": "2026-10-22T08:00:00.000Z"
      },
      "mrr_cents": 9900,
      "currency": "usd"
    }
  }
}
```

`data.response` has the same shape as [`GET /v1/responses/{id}`](/api-reference/overview). It describes the answer **when the request is sent**, so a retry carries the latest verdict. `created_at` is when the event happened. The values above are illustrative.

* **Answer quickly** with any `2xx`. Do the work after you respond. Rumorly waits at most 10 seconds.
* **Deduplicate on `id`** (the same value as `Rumorly-Delivery`). A retry reuses it.
* **Order isn't guaranteed.** Compare `classification.classified_at` if you keep only the latest verdict.
* **Repeats are merged.** If an answer changes again while its event is still waiting to be delivered, you get one event with the latest state. Saving the same override twice sends nothing new.

## Verify the signature

`Rumorly-Signature` is `t=<unix seconds>,v1=<signature>`. The signature is the hex HMAC-SHA256 of `{t}.{raw body}`, keyed with your whole secret string, `whsec_` prefix included. To check it:

1. Read the **raw** request body, before any JSON parsing.
2. Compute the HMAC and compare it with `v1` in constant time.
3. Reject a `t` more than 5 minutes from your clock, to stop replays.

```js Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.RUMORLY_WEBHOOK_SECRET; // whsec_…

function verify(rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

const app = express();
app.post("/rumorly", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("Rumorly-Signature") ?? "")) return res.status(400).end();
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(204).end(); // answer first, then do the work
  handle(event);
});
```

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

SECRET = os.environ["RUMORLY_WEBHOOK_SECRET"].encode()  # whsec_…
app = Flask(__name__)

def verify(raw_body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/rumorly")
def rumorly():
    if not verify(request.get_data(), request.headers.get("Rumorly-Signature", "")):
        abort(400)
    event = json.loads(request.get_data())
    handle(event)
    return "", 204
```

## Retries and failures

* Anything other than a `2xx` within 10 seconds is a failed attempt. A redirect counts as a failure, since Rumorly doesn't follow redirects. Use the final URL.
* A failed delivery is retried up to 8 times with exponential backoff, over roughly 2 to 4 hours.
* After **10 deliveries in a row** fail every retry, the endpoint is switched off and the reason is shown in Settings. Any successful delivery resets the count. Fix the receiver, then turn the endpoint back on. Events from the time it was off are not sent again.
* Test events are tried once and never count toward switching an endpoint off.
* A workspace can have at most 5,000 events waiting for delivery. Past that, new events are dropped and counted on the endpoint until the backlog clears.

The delivery log keeps the event, the status code, the number of attempts, the duration and the error for each delivery, for 30 days. It never stores the body.

## Security

* Only `https://` URLs, without `user:password@`.
* Rumorly resolves your host each time it sends and refuses private, loopback, link-local, cloud-metadata and other reserved addresses. The connection goes to the address it checked.
* If an answer is erased (`DELETE /v1/people`) before its event goes out, the event is skipped. Erasure doesn't reach copies already delivered to your endpoint, so erase them there too.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.