Webhooks
Proton sends a signed POST to the company's address when something happens in the automations: a run finishes, a
batch finishes or does not run, an exception opens, is assigned or resolved, a runner goes down or comes back. This is
the path to the company's monitoring, ticketing or messaging tool. To read the same events on demand, use
GET /v1/events.
Webhooks are part of the Enterprise plan. They are registered in Settings > Alerts and webhooks, on the Webhooks tab: the name, the address and the events (or all of them).
What arrives
Delivery follows the Standard Webhooks standard:
POST https://monitoring.example.com/proton
content-type: application/json
user-agent: Proton-Webhooks/1.0
webhook-id: evt_1532
webhook-timestamp: 1760886000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"type": "run.finished",
"timestamp": "2026-10-19T15:00:00.000Z",
"version": "1",
"data": {
"organizationId": 12,
"link": "https://app.protoncloud.com.br/automations/center/dataset/40/cycle/7/dataset-run/981/details",
"run": {
"id": 981,
"status": "Failed",
"endReason": "technical_error",
"startedAt": "2026-10-19T14:58:00.000Z",
"finishedAt": "2026-10-19T15:00:00.000Z",
"durationMs": 120000,
"correlationId": "7f6c2d2e-5f0b-4a4a-9b9b-3c1f2a9e0d11",
"retryScheduled": false,
"link": "https://app.protoncloud.com.br/automations/center/dataset/40/cycle/7/dataset-run/981/details"
},
"automation": { "id": 5, "name": "Supplier registration" },
"dataset": { "id": 40, "name": "Branch 01" },
"group": { "id": 3, "name": "Operation", "purpose": "operation" },
"cycle": { "id": 7, "name": "October" },
"environment": { "id": 2, "name": "Production", "production": true },
"runner": { "id": 9, "name": "rpa-01", "hostname": "rpa-01" }
}
}
The events
| Type | When | Fields in data |
|---|---|---|
run.finished | A run finished | run (status, end reason, start, end, duration, Correlation ID), automation, dataset, group, cycle, environment, runner |
batch.finished | A batch finished | batch (total, passed, failed, stopped), group, cycle, environment, runner |
batch.not_run | A schedule did not go out | batch (with the reason), group, cycle, environment, runner |
exception.opened, exception.assigned, exception.resolved | An operation exception opened, was assigned or resolved | exception (kind, status, occurrences, reason, errorSummary, assignee), automation, dataset, group, cycle, environment |
runner.offline, runner.online | A runner went down (after 2 minutes away) or came back | runner (name, owner, hostname, offlineSince, shared) |
webhook.test | The test from the screen | organizationId, message |
The run's end reason (endReason) is one of: passed, business_error, technical_error, environment_error,
stopped, not_dispatched, runner_lost.
The body version (version) only changes if a field is removed or changes meaning. A new field may show up at any
time: treat what you do not know as optional.
Checking the signature
The webhook secret (whsec_...) is shown only once, at registration and on rotation. The signature is the HMAC-SHA256
of webhook-id.webhook-timestamp.body, with the base64 key that comes after whsec_, encoded in base64 and prefixed
with v1,. Always check the body exactly as it arrived, before parsing the JSON, and reject a message with a
webhook-timestamp that is too old (more than 5 minutes).
- Python
- Node.js
import base64
import hashlib
import hmac
import time
def valid_signature(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
message_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
return False
content = f"{message_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()
for signature in headers["webhook-signature"].split(" "):
version, _, value = signature.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return False
import crypto from 'node:crypto';
export function validSignature(secret, headers, body, tolerance = 300) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
// body: the text exactly as it arrived, before JSON.parse
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64');
return headers['webhook-signature'].split(' ').some((signature) => {
const [version, value] = signature.split(',');
return version === 'v1'
&& value.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
});
}
The Standard Webhooks libraries (for Python, Node.js, Java, Go, C# and others) do this check.
Delivery and retries
- Response: any
2xxwithin 10 seconds counts as delivered. Answer quickly and process later. - Retries: without a
2xx, Proton tries again with increasing waits (30 seconds, 1 minute, 2 minutes... up to 1 hour between attempts), up to 8 attempts. - Repeats: the
webhook-idis the same in every attempt of the same event. Keep the ids you received and discard the repeated one. - Address:
httpsonly, at a public address. Redirects are not followed. - Follow-up: the History tab of Alerts and webhooks shows each delivery, with the result, the HTTP status and
the error, and lets you resend the one that failed. The test button sends a
webhook.test.