REST API Webhooks

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.

View as Markdown

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#

EventWhen it is sent
run.finishedA tracking run finished or failed
question.flippedA question moved up: lost to mixed or won, or mixed to won
alert.competitor_moveA competitor overtook you on two or more questions
alert.recheck_resultA fix re-check finished
alert.accuracyAn answer contradicted an approved fact
alert.run_statusA run failed, or too many answers did not come back
alert.usageThis month's answer allowance crossed 80% or 100%
alert.sentimentAI described you negatively
alert.daily_moveA 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:

HeaderValue
Content-Typeapplication/json
Prefera-EventThe event name, such as run.finished
Prefera-Signaturet=<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:

Eventdata
run.finishedrun_id, status (done or failed), trigger, country, finished_at
question.flippedrun_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.
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 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 and the REST API.