Webhooks
Get a signed HTTPS call when a forecast starts, finishes or fails, and check the signature on your server.
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
- Open Settings and choose Webhooks.
- Choose Add webhook, enter your HTTPS address and, if you like, a description.
- 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.
The answer, with status 201:
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.
| 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:
- Read
tand everyv1value fromX-Prolepsia-Signature. There can be twov1values for a day after you rotate the secret. - Compute HMAC-SHA256, keyed with your whole signing secret (including
whsec_), over the text<t>.followed by the raw request body. - Accept the call when your result, written as lowercase hex, equals one of the
v1values. Compare in constant time. - Refuse a
tmore 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.
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.
Delivery history
Every delivery and every attempt is kept. Use them to find out why your server missed a call.
GET /webhook-deliverieslists deliveries, newest first. Filter withwebhookIdandstatus:pending,in_progress,retrying,succeeded,failedorcancelled.GET /webhook-deliveries/{deliveryId}reads one, withattemptCount,retriesRemaining,nextAttemptAt, the lastresponseStatus,errorMessageanddurationMs.GET /webhook-deliveries/{deliveryId}/attemptslists each HTTP attempt.POST /webhook-deliveries/{deliveryId}/replaysends afailedorcancelleddelivery 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.
A delivery looks like this:
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.