Docs
For developers

Webhooks

Get a signed HTTPS call when a forecast starts, finishes or fails, and check the signature on your server.

View as Markdown

A webhook is an HTTPS address on your server that Prolepsia calls when something happens to a forecast. Instead of checking a forecast every few minutes, you get a signed POST when it finishes.

  • Events: a forecast started, completed, failed or was cancelled, and its written report finished.
  • Security: every call carries an HMAC-SHA256 signature made with a secret only you and Prolepsia know.
  • Reliability: failed calls are retried for about 3 hours. Every attempt is logged, and you can send a delivery again.
  • Limits: up to 5 webhooks for each user.

Add a webhook

In the app

  1. Open Settings and choose Webhooks.
  2. Choose Add webhook, enter your HTTPS address and, if you like, a description.
  3. Copy the signing secret. It starts with whsec_, and Prolepsia shows it only once.

A webhook added in the app gets forecast.completed events. To pick other events, test, pause or edit a webhook, use the API below or the MCP tools.

With the API

POST /webhooks needs an API key with the forecasts:write scope. See the API page for keys and the prolepsia helper the TypeScript and Python examples use.

ts
const webhook = await prolepsia("/webhooks", {  body: {    url: "https://example.com/prolepsia/webhook",    description: "Forecast results for the pricing service",    eventKinds: ["forecast.completed", "forecast.failed"],  },});// Store this secret now. Prolepsia shows it only once.console.log(webhook.id, webhook.signingSecret);

The answer, with status 201:

json
{  "id": "0b333c41-1775-4439-be24-55769f41153c",  "type": "webhook",  "url": "https://example.com/prolepsia/webhook",  "description": "Forecast results for the pricing service",  "enabled": true,  "secretPrefix": "L3NnHh3VzoXX",  "eventKinds": ["forecast.completed", "forecast.failed"],  "lastDeliveryAt": null,  "lastDeliveryStatus": null,  "createdAt": "2026-10-12T07:46:48.177Z",  "updatedAt": "2026-10-12T07:46:48.177Z",  "signingSecret": "whsec_L3NnHh3VzoXX_tgl0KlSJPoM2-oM8sg5B0LIQMXtKkBA_KSQkvID_EXg"}

Leave out eventKinds to get only forecast.completed. Reading the webhook later never shows the secret again, only its secretPrefix.

Rules for the address

  • It must use https, and its host must resolve to a public internet address. Private, loopback and link-local addresses are refused, both when you add the webhook and before every call.
  • It can be up to 500 characters, with no user name, password or # fragment.
  • Prolepsia does not follow redirects. Give the final address.
  • To test on your own computer, expose your local server through a tunnel, such as ngrok or Cloudflare Tunnel, and register the tunnel's HTTPS address.

Events

EventSent when
forecast.startedA forecast is created and queued to run.
forecast.completedA forecast has its answer. Read it from the API.
forecast.failedA forecast failed.
forecast.cancelledA forecast was cancelled.
forecast.report.completedThe written report of a forecast is done, or failed for the last time. data.reportStatus says which. It arrives a little after forecast.completed.
webhook.testYou asked for a test call. You do not subscribe to it.

A report that failed can be written again from the app. Its second try sends another forecast.report.completed, so a failed report event can be followed by a completed one for the same forecast.

What Prolepsia sends

Each call is a POST with a JSON body. The body carries ids and a link, never the forecast itself, so fetch the result from the API.

json
{  "createdAt": "2026-10-12T08:31:02.114Z",  "data": {    "forecastId": "695142eb-203f-415b-81c7-ccaf2747b0a6",    "forecastUrl": "https://app.prolepsia.com/api/v1/external/forecasts/2e1cc0b7-fa66-4ad4-a583-6da0e1f07ac7",    "questionId": "2c54dab4-f4e6-439d-a35d-1b7c71205259",    "taskId": "2e1cc0b7-fa66-4ad4-a583-6da0e1f07ac7"  },  "id": "prolepsia:forecast.completed:695142eb-203f-415b-81c7-ccaf2747b0a6",  "type": "forecast.completed"}
FieldWhat it holds
idThe event's id. The same event sent again keeps its id.
typeThe event, from the table above.
createdAtWhen the event happened, in UTC.
data.taskIdThe forecast task. Read it with GET /forecasts/{taskId}.
data.questionIdThe question.
data.forecastIdThe forecast, or null when there is none yet: on forecast.started, forecast.failed and forecast.cancelled.
data.forecastUrlThe API address of the task, ready to fetch with your API key.
data.reportStatusOnly on forecast.report.completed: completed or failed.

A webhook.test body has data: {"test": true, "webhookId": "..."}.

The body is written with its keys in alphabetical order and no spaces. Check the signature against the raw bytes you received, before you parse them.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentProlepsia-Webhooks/1
X-Prolepsia-Signaturet=<Unix time in seconds>,v1=<signature>. See below.
X-Prolepsia-Event-IdThe body's id.
X-Prolepsia-Event-TypeThe body's type.
X-Prolepsia-Delivery-IdThe id of this delivery. Retries and replays keep it, so use it to ignore a call you already handled.

Check the signature

Check every call before you trust it:

  1. Read t and every v1 value from X-Prolepsia-Signature. There can be two v1 values for a day after you rotate the secret.
  2. Compute HMAC-SHA256, keyed with your whole signing secret (including whsec_), over the text <t>. followed by the raw request body.
  3. Accept the call when your result, written as lowercase hex, equals one of the v1 values. Compare in constant time.
  4. Refuse a t more than 5 minutes away from your clock, so an old call cannot be sent again later.

Both servers below run as they are. They check the signature, ignore a delivery they have already handled, and answer 204 straight away. Set PROLEPSIA_WEBHOOK_SECRET to your signing secret before you start one.

ts
// receiver.ts. Runs on Node.js 20 or later (npx tsx receiver.ts) and Bun.import { createHmac, timingSafeEqual } from "node:crypto";import { createServer } from "node:http";
const SECRET = process.env.PROLEPSIA_WEBHOOK_SECRET ?? ""; // whsec_...const TOLERANCE_SECONDS = 300;const seenDeliveries = new Set<string>();
/** Return true when one v1 signature in the header matches the body. */function verifySignature(secret: string, header: string, body: Buffer): boolean {  let timestamp = "";  const signatures: string[] = [];  for (const part of header.split(",")) {    const [key, value = ""] = part.trim().split("=", 2);    if (key === "t") timestamp = value;    if (key === "v1") signatures.push(value);  }  if (!/^\d+$/.test(timestamp) || signatures.length === 0) return false;  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {    return false;  }  const expected = createHmac("sha256", secret)    .update(`${timestamp}.`)    .update(body)    .digest();  return signatures.some((signature) => {    const given = Buffer.from(signature, "hex");    return given.length === expected.length && timingSafeEqual(given, expected);  });}
createServer((req, res) => {  const chunks: Buffer[] = [];  req.on("data", (chunk: Buffer) => chunks.push(chunk));  req.on("end", () => {    const body = Buffer.concat(chunks);    const header = String(req.headers["x-prolepsia-signature"] ?? "");    if (!verifySignature(SECRET, header, body)) {      res.writeHead(400).end();      return;    }    const deliveryId = String(req.headers["x-prolepsia-delivery-id"]);    if (!seenDeliveries.has(deliveryId)) {      seenDeliveries.add(deliveryId);      const event = JSON.parse(body.toString("utf8"));      // Start your own work here, such as fetching event.data.forecastUrl.      console.log(event.type, event.data);    }    res.writeHead(204).end();  });}).listen(8787);

In a web framework, read the raw body for the check. In Express use express.raw({ type: "application/json" }) on the webhook route, in Flask use request.get_data(), and in FastAPI use await request.body(). A body that was parsed and written out again no longer matches the signature.

Keep the list of handled deliveries in your database rather than in memory, so it survives a restart.

Answer quickly

Answer with any 2xx status within 10 seconds. Prolepsia reads at most 1 KB of your answer and ignores it. Do slow work, such as fetching the forecast, after you answer, for example in a background job.

Retries

Your serverWhat happens
Answers 2xxThe delivery succeeded.
Does not answer within 10 seconds, cannot be reached, or answers 408, 425, 429 or 5xxProlepsia tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours, then stops. That is 6 attempts in all.
Answers anything else, including a redirectThe delivery fails at once, with no retry.

Every attempt is signed again with a new t. Because a call can arrive more than once, and events can arrive out of order, use X-Prolepsia-Delivery-Id to skip duplicates and read the task's status from the API rather than trusting the order of calls.

Test, pause, rotate and delete

ToDo this
Send a test callPOST /webhooks/{webhookId}/test. It sends a signed webhook.test, even while the webhook is paused.
Pause or resumePATCH /webhooks/{webhookId} with {"enabled": false} or true. Events that happen while it is paused are not sent later.
Change the address or eventsPATCH /webhooks/{webhookId} with url, description or eventKinds. A new url cancels deliveries still waiting; a call already sent can still arrive.
Get a new secretPOST /webhooks/{webhookId}/rotate, or Rotate secret in Settings. The new secret is shown once. For 24 hours each call carries two signatures, one from the new secret and one from the old, so you can switch over without missing a call.
DeleteDELETE /webhooks/{webhookId}, or Delete in Settings. Deliveries still waiting are cancelled. The delivery history stays.

Testing and replaying share a limit of 20 calls a minute for each user.

ts
const test = await prolepsia(`/webhooks/${webhook.id}/test`, { method: "POST" });console.log(test.id, test.status); // a delivery id, "pending"

Delivery history

Every delivery and every attempt is kept. Use them to find out why your server missed a call.

  • GET /webhook-deliveries lists deliveries, newest first. Filter with webhookId and status: pending, in_progress, retrying, succeeded, failed or cancelled.
  • GET /webhook-deliveries/{deliveryId} reads one, with attemptCount, retriesRemaining, nextAttemptAt, the last responseStatus, errorMessage and durationMs.
  • GET /webhook-deliveries/{deliveryId}/attempts lists each HTTP attempt.
  • POST /webhook-deliveries/{deliveryId}/replay sends a failed or cancelled delivery again to the webhook's current address. The webhook must exist and be turned on. The delivery keeps its id and body, and gets a fresh set of retries.
ts
const failed = await prolepsia(  `/webhook-deliveries?webhookId=${webhook.id}&status=failed`,);for (const delivery of failed.data) {  console.log(delivery.id, delivery.responseStatus, delivery.errorMessage);  await prolepsia(`/webhook-deliveries/${delivery.id}/replay`, { method: "POST" });}

A delivery looks like this:

json
{  "id": "d47b3ca6-94ee-4807-9556-fa6673a0ed28",  "webhookId": "0b333c41-1775-4439-be24-55769f41153c",  "endpointUrl": "https://example.com/prolepsia/webhook",  "eventKind": "webhook.test",  "status": "succeeded",  "isTest": true,  "attemptCount": 1,  "retriesRemaining": 5,  "nextAttemptAt": null,  "responseStatus": 204,  "errorMessage": "",  "durationMs": 5,  "deliveredAt": "2026-10-12T07:48:54.957Z",  "createdAt": "2026-10-12T07:46:51.981Z"}

deliveredAt is when the last attempt finished, whether it succeeded or not.

Use webhooks from an AI agent

The MCP server has the same actions as tools: create_webhook, list_webhooks, get_webhook, update_webhook, delete_webhook, rotate_webhook_secret, test_webhook, list_webhook_deliveries, get_webhook_delivery, list_webhook_delivery_attempts and replay_webhook_delivery.

Analytics cookies

Prolepsia uses analytics cookies to learn how people use the app. They link your visits together and can record how you move through pages, with all text and typed values hidden. Until you accept, Prolepsia only counts visits anonymously, without cookies. Read the privacy policy