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.
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.
- Open Integrations. Find Webhooks in the Notifications section.
- Click Add endpoint.
- Enter the URL. It must be
httpsand on the public internet. Addresses that point to private networks are refused, and so are URLs with a username or password. - Tick the Events you want. All are ticked by default. Click Add endpoint.
- 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:
{
"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.
- Read the raw body before you parse it.
- Take
tandv1fromPrefera-Signature. - Reject the request if
tis more than 5 minutes from your clock. - Compute the HMAC and compare it to
v1in constant time.
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:
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
2xxstatus. Prefera waits up to 10 seconds. - On a
5xx, a timeout or a network failure Prefera tries once more, after a short pause. A4xxis 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 and the REST API.