# Webhooks

> Get a signed JSON POST to your own URL when a run finishes, a question moves or an alert fires. Events, payload, signature check and retries.

Source: https://prefera.si/docs/api/webhooks
Docs index for agents: https://prefera.si/llms.txt

An outgoing webhook is a POST that Prefera sends to a URL you own when something happens in your workspace. Webhooks are included with every paid plan.

## Add an endpoint

Owners and editors can add endpoints.

1. Open **Integrations**. Find **Webhooks** in the **Notifications** section.
2. Click **Add endpoint**.
3. Enter the **URL**. It must be `https` and on the public internet. Addresses that point to private networks are refused, and so are URLs with a username or password.
4. Tick the **Events** you want. All are ticked by default. Click **Add endpoint**.
5. Copy the signing secret. It is shown once. Prefera stores it encrypted and cannot show it again.

A workspace can have up to 10 endpoints. Each endpoint shows its last delivery, for example **Delivered**, **Last response 500**, **Could not be reached** or **Blocked address**. Click **Send test event** to try it. **Delete** removes it.

## Events

| Event | When it is sent |
|---|---|
| `run.finished` | A tracking run finished or failed |
| `question.flipped` | A question moved up: lost to mixed or won, or mixed to won |
| `alert.competitor_move` | A competitor overtook you on two or more questions |
| `alert.recheck_result` | A fix re-check finished |
| `alert.accuracy` | An answer contradicted an approved fact |
| `alert.run_status` | A run failed, or too many answers did not come back |
| `alert.usage` | This month's answer allowance crossed 80% or 100% |
| `alert.sentiment` | AI described you negatively |
| `alert.daily_move` | A daily question moved, with 3 days in agreement |

An endpoint only receives the events it subscribed to.

## Request

Prefera sends a `POST` with a JSON body and these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `Prefera-Event` | The event name, such as `run.finished` |
| `Prefera-Signature` | `t=<unix seconds>,v1=<hex>` |

The body has the same envelope for every event:

```json
{
  "id": "0b6f6c1e-5a52-4a0e-9d7b-0f4f2c0d2a11",
  "event": "run.finished",
  "sent_at": "2026-11-06T09:30:00.000Z",
  "workspace_id": "workspace id",
  "brand_id": "brand id",
  "data": {
    "run_id": "run id",
    "status": "done",
    "trigger": "schedule",
    "country": "us",
    "finished_at": "2026-11-06T09:29:41.000Z"
  }
}
```

`id` is new for each delivery. The `data` fields depend on the event:

| Event | `data` |
|---|---|
| `run.finished` | `run_id`, `status` (`done` or `failed`), `trigger`, `country`, `finished_at` |
| `question.flipped` | `run_id`, `country`, `question_id`, `question`, `before`, `after` |
| `alert.*` | `alert_id`, `kind`, `title`, `body`, `href` (a full link into the app) |

A test event from **Send test event** uses the first event the endpoint subscribed to, and its `data` is `{ "test": true, "message": "Prefera test event. Nothing in your workspace changed." }`.

## Verify the signature

`v1` is the hex HMAC-SHA256 of the string `<t>.<raw body>`: the timestamp from the header, a dot, then the raw request body. The key is your signing secret.

1. Read the raw body before you parse it.
2. Take `t` and `v1` from `Prefera-Signature`.
3. Reject the request if `t` is more than 5 minutes from your clock.
4. Compute the HMAC and compare it to `v1` in constant time.

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

export function verifyPrefera(secret: string, header: string, rawBody: string): boolean {
  const timestamp = /(?:^|,)t=(\d+)/.exec(header)?.[1];
  const given = /(?:^|,)v1=([a-f0-9]+)/i.exec(header)?.[1];
  if (!timestamp || !given) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(given.toLowerCase());
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Use it with the raw body. In Express, that means `express.raw`, not a re-serialised object:

```ts
import express from "express";

const app = express();

app.post("/prefera", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  if (!verifyPrefera(process.env.PREFERA_WEBHOOK_SECRET ?? "", req.get("Prefera-Signature") ?? "", body)) {
    return res.status(401).end();
  }
  const payload = JSON.parse(body);
  console.log(req.get("Prefera-Event"), payload.data);
  res.status(204).end();
});
```

## Delivery and retries

- Reply with a `2xx` status. Prefera waits up to 10 seconds.
- On a `5xx`, a timeout or a network failure Prefera tries once more, after a short pause. A `4xx` is final and is not retried.
- A URL that resolves to a private address is blocked.
- The last result is shown next to the endpoint under **Integrations**.

Because there is only one retry, make your handler quick and keep the `id` if you need to ignore a repeated delivery.

See also [Slack, email and webhooks](/docs/integrations/notifications) and the [REST API](/docs/api).
