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",
"tasks": [
{
"name": "export-1",
"operation": "export/url",
"status": "finished",
"result": {
"url": "https://example-storage.invalid/signed-download-link",
"urlExpiresAt": "2026-10-10T13:00:00.000Z"
}
}
]
}
}
}In a job.finished event, the export task carries a signed download link in result.url and its expiry in result.urlExpiresAt. Download the file before it expires, or call GET /jobs/{id} to get a fresh link. The upload URL is never included in any event, not even in job.created.
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 as the key (use the whole string, including the whsec_ prefix), 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 complete receiver that does this check. Save it as receiver.ts and run it with WEBHOOK_SECRET=whsec_… bun receiver.ts:
/**
* Minimal Convertere webhook receiver for trying the API end to end.
.
*
* WEBHOOK_SECRET=whsec_... bun receiver.ts
*
* It verifies `Convertere-Signature` (HMAC-SHA256 over "<t>.<raw body>", 5-minute
* tolerance), de-duplicates on `Convertere-Event-Id`, and logs each event.
* Optional: `WEBHOOK_SECRET_PREVIOUS` (during a secret rotation) and `PORT`.
* Expose it with a tunnel (https is required for registration), or use a hosted
* receiver such as webhook.site and skip this script.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { serve } from "bun";
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
throw new Error("WEBHOOK_SECRET is required");
}
const secrets = [secret, process.env.WEBHOOK_SECRET_PREVIOUS].filter(
(value): value is string => Boolean(value),
);
const processedEventIds = new Set<string>();
const port = Number(process.env.PORT ?? 8787);
const TOLERANCE_SECONDS = 300;
function verify(rawBody: string, header: string | null): boolean {
if (!header) {
return false;
}
const timestampMatch = header.match(/(?:^|,)t=(\d+)(?=,|$)/);
if (!timestampMatch) {
return false;
}
const timestamp = Number(timestampMatch[1]);
if (
!Number.isFinite(timestamp) ||
Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS
) {
return false;
}
const signatures = [...header.matchAll(/(?:^|,)v1=([0-9a-fA-F]+)/g)].map(
(match) => match[1] as string,
);
return secrets.some((candidate) => {
const expected = createHmac("sha256", candidate)
.update(`${timestampMatch[1]}.${rawBody}`)
.digest();
return signatures.some((value) => {
const received = Buffer.from(value, "hex");
return (
received.length === expected.length &&
timingSafeEqual(received, expected)
);
});
});
}
const server = serve({
port,
async fetch(request) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const rawBody = await request.text();
if (!verify(rawBody, request.headers.get("Convertere-Signature"))) {
return new Response("Invalid signature", { status: 400 });
}
try {
const event = JSON.parse(rawBody);
const eventId = request.headers.get("Convertere-Event-Id");
if (eventId && processedEventIds.has(eventId)) {
return new Response("OK", { status: 200 });
}
if (eventId) {
processedEventIds.add(eventId);
}
console.log({
event: event.event,
job_id: event.data?.job?.id,
status: event.data?.job?.status,
test: event.test === true,
});
return new Response("OK", { status: 200 });
} catch {
return new Response("Invalid JSON", { status: 400 });
}
},
});
console.log(`Webhook receiver listening on http://localhost:${server.port}`);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",
"mode": "live",
"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.
Test and live endpoints
Every endpoint has a mode, taken from the API key that created it (or chosen in the dashboard):
- A test endpoint receives only events from jobs made with test keys.
- A live endpoint receives only events from jobs made with live keys.
- A test key lists, tests, rotates and deletes test endpoints only. Asking for a live endpoint's id with a test key returns
404, as if it did not exist. The same holds the other way round. - Endpoints created before this separation are live endpoints.
To rehearse an integration, register a second endpoint with a test key, point it at your staging server, and run jobs with a test key.
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.