# Prolepsia webhooks

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](#use-webhooks-from-an-ai-agent).

### With the API

`POST /webhooks` needs an API key with the `forecasts:write` scope. See [the API page](https://app.prolepsia.com/docs/developers/api#make-your-first-call) 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);
```

```python
webhook = prolepsia(
    "POST",
    "/webhooks",
    json={
        "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.
print(webhook["id"], webhook["signingSecret"])
```

```bash
curl -sS -X POST "$PROLEPSIA_BASE_URL/webhooks" \
  -H "Authorization: Bearer $PROLEPSIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/prolepsia/webhook",
    "description": "Forecast results for the pricing service",
    "eventKinds": ["forecast.completed", "forecast.failed"]
  }'
```

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

| Event | Sent when |
| --- | --- |
| `forecast.started` | A forecast is created and queued to run. |
| `forecast.completed` | A forecast has its answer. Read it from the API. |
| `forecast.failed` | A forecast failed. |
| `forecast.cancelled` | A forecast was cancelled. |
| `forecast.report.completed` | The 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.test` | You 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"
}
```

| Field | What it holds |
| --- | --- |
| `id` | The event's id. The same event sent again keeps its id. |
| `type` | The event, from the table above. |
| `createdAt` | When the event happened, in UTC. |
| `data.taskId` | The forecast task. Read it with `GET /forecasts/{taskId}`. |
| `data.questionId` | The question. |
| `data.forecastId` | The forecast, or `null` when there is none yet: on `forecast.started`, `forecast.failed` and `forecast.cancelled`. |
| `data.forecastUrl` | The API address of the task, ready to fetch with your API key. |
| `data.reportStatus` | Only 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

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `Prolepsia-Webhooks/1` |
| `X-Prolepsia-Signature` | `t=<Unix time in seconds>,v1=<signature>`. See below. |
| `X-Prolepsia-Event-Id` | The body's `id`. |
| `X-Prolepsia-Event-Type` | The body's `type`. |
| `X-Prolepsia-Delivery-Id` | The 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);
```

```python
# receiver.py. Runs on Python 3.9 or later with no packages: python receiver.py
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["PROLEPSIA_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300
seen_deliveries: set[str] = set()


def verify_signature(secret: str, header: str, body: bytes) -> bool:
    """Return True when one v1 signature in the header matches the body."""
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    # Compare bytes: comparing two str values raises on a non-ASCII character.
    return any(
        hmac.compare_digest(expected.encode(), sig.encode("utf-8", "replace"))
        for sig in signatures
    )


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        header = self.headers.get("X-Prolepsia-Signature", "")
        if not verify_signature(SECRET, header, body):
            self.send_response(400)
            self.end_headers()
            return
        delivery_id = self.headers["X-Prolepsia-Delivery-Id"]
        if delivery_id not in seen_deliveries:
            seen_deliveries.add(delivery_id)
            event = json.loads(body)
            # Start your own work here, such as fetching event["data"]["forecastUrl"].
            print(event["type"], event["data"], flush=True)
        self.send_response(204)
        self.end_headers()


HTTPServer(("0.0.0.0", 8787), Handler).serve_forever()
```

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 server | What happens |
| --- | --- |
| Answers `2xx` | The delivery succeeded. |
| Does not answer within 10 seconds, cannot be reached, or answers `408`, `425`, `429` or `5xx` | Prolepsia 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 redirect | The 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

| To | Do this |
| --- | --- |
| Send a test call | `POST /webhooks/{webhookId}/test`. It sends a signed `webhook.test`, even while the webhook is paused. |
| Pause or resume | `PATCH /webhooks/{webhookId}` with `{"enabled": false}` or `true`. Events that happen while it is paused are not sent later. |
| Change the address or events | `PATCH /webhooks/{webhookId}` with `url`, `description` or `eventKinds`. A new `url` cancels deliveries still waiting; a call already sent can still arrive. |
| Get a new secret | `POST /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. |
| Delete | `DELETE /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"
```

```python
test = prolepsia("POST", f"/webhooks/{webhook['id']}/test")
print(test["id"], test["status"])  # a delivery id, "pending"
```

```bash
curl -sS -X POST "$PROLEPSIA_BASE_URL/webhooks/0b333c41-1775-4439-be24-55769f41153c/test" \
  -H "Authorization: Bearer $PROLEPSIA_API_KEY"
```

## 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" });
}
```

```python
failed = prolepsia(
    "GET",
    "/webhook-deliveries",
    params={"webhookId": webhook["id"], "status": "failed"},
)
for delivery in failed["data"]:
    print(delivery["id"], delivery["responseStatus"], delivery["errorMessage"])
    prolepsia("POST", f"/webhook-deliveries/{delivery['id']}/replay")
```

```bash
curl -sS "$PROLEPSIA_BASE_URL/webhook-deliveries?status=failed" \
  -H "Authorization: Bearer $PROLEPSIA_API_KEY"
```

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](https://app.prolepsia.com/docs/developers/mcp) 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`.
