Webhooks
Receive and verify signed job event notifications.
Webhooks tell your server when a job changes, so you do not have to poll. Register an https endpoint, choose the events you want, and verify the signature on every request.
Events and payload
| Event | Sent when |
|---|---|
job.created | A job was created. |
job.finished | A job finished; the result link is in the payload. |
job.failed | A job failed (status: "error"). |
job.cancelled | A job was cancelled. |
Each delivery is a POST with a JSON body: a stable event id, the event type, created_at, and data.job, a snapshot of the job in the same shape as GET /jobs/{id}. Test deliveries add "test": true.
{
"id": "evt_5f1c2a3e-8c1b-4d6e-9a53-2d1f7a0b9c11",
"event": "job.finished",
"created_at": "2026-10-10T12:00:00.000Z",
"data": {
"job": {
"id": "11111111-1111-4111-8111-111111111111",
"status": "finished"
}
}
}Headers sent with every delivery:
| Header | Value |
|---|---|
Content-Type | application/json |
Convertere-Signature | t=<unix seconds>,v1=<hex> (see below) |
Convertere-Event-Id | The same evt_… id as the body. It stays the same across retries, so deduplicate on it. |
User-Agent | Convertere-Webhooks/1 |
Answer with any 2xx within 10 seconds. Redirects are not followed.
Verify signatures
Convertere-Signature is t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 over the string "<t>.<raw request body>" using your endpoint's signing secret (it starts with whsec_), and compare it with v1 in constant time. Always use the raw body bytes, not a re-serialized copy. Reject requests whose t is more than 300 seconds away from your clock; that blocks replays of a captured request.
While a secret is being rotated (see below) the header carries two signatures, t=…,v1=<new>,v1=<old>. Accept the request if any v1 matches any secret you currently hold.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const timestampText = header.match(/(?:^|,)t=(\d+)(?=,|$)/)?.[1];
const signatures = [...header.matchAll(/(?:^|,)v1=([0-9a-fA-F]+)/g)].map(
(match) => match[1],
);
const timestamp = Number(timestampText);
if (
!timestampText ||
!Number.isFinite(timestamp) ||
Math.abs(now - timestamp) > 300
)
return false;
const expected = createHmac("sha256", secret)
.update(`${timestampText}.${rawBody}`)
.digest();
return signatures.some((value) => {
const received = Buffer.from(value, "hex");
return (
received.length === expected.length && timingSafeEqual(received, expected)
);
});
}import hashlib
import hmac
import re
import time
def verify(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
timestamp_match = re.search(r"(?:^|,)t=(\d+)(?=,|$)", header)
signatures = re.findall(r"(?:^|,)v1=([0-9a-fA-F]+)", header)
if not timestamp_match:
return False
timestamp_text = timestamp_match.group(1)
if abs((now or int(time.time())) - int(timestamp_text)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp_text.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(value.lower(), expected) for value in signatures)A ready-to-run receiver that does this check lives in the repository at scripts/webhook-receiver.ts (run it with WEBHOOK_SECRET=whsec_… bun scripts/webhook-receiver.ts).
Create and manage endpoints
POST /webhooks registers an endpoint (scope webhook.write):
{
"url": "https://example.com/hooks/convertere",
"events": ["job.finished", "job.failed"],
"description": "Production notifications"
}The 201 response is the webhook plus its signing secret. The secret is shown only once, so store it now.
{
"id": "33333333-3333-4333-8333-333333333333",
"url": "https://example.com/hooks/convertere",
"events": ["job.finished", "job.failed"],
"description": "Production notifications",
"enabled": true,
"disabled_reason": null,
"disabled_at": null,
"last_success_at": null,
"created_at": "2026-10-10T12:00:00.000Z",
"secret": "whsec_example"
}- The URL must be
https://and resolve to public addresses; private, loopback, link-local and cloud-metadata destinations are refused when you register and again on every delivery. An organization can have up to 20 endpoints. GET /webhooks(scopewebhook.read) lists them as{"data": [...]}withenabled,disabled_reasonandlast_success_at. Secrets are never returned again.DELETE /webhooks/{id}removes an endpoint and its delivery history (204).POST /webhooks/{id}/testqueues a signed samplejob.finishedevent (with"test": true) and answers202with{"delivery_id": "…", "status": "queued"}. It is sent whatever events the endpoint subscribes to.
The dashboard (Settings → Webhooks) offers the same actions for owners and admins, plus Rotate secret, Re-enable, and a list of recent deliveries. Rotating issues a new secret and keeps the old one signing for 24 hours so you can switch over without losing events.
Retries and disabling
A 2xx answer means delivered. Anything else (a non-2xx status, a timeout, a connection error) is retried after 60 seconds, 5 minutes, 15 minutes and 1 hour: five attempts in total, including the first. An HTTP 410 Gone stops retries and disables the endpoint immediately; so does five consecutive days without a single successful delivery. A disabled endpoint shows enabled: false and a disabled_reason (gone_410 or failing_5_days); re-enable it in the dashboard once the problem is fixed. Delivery history is kept for 30 days.
The job itself is always authoritative: if a webhook never arrives, GET /jobs/{id} still shows the true state.