Public API

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

EventSent when
job.createdA job was created.
job.finishedA job finished; the result link is in the payload.
job.failedA job failed (status: "error").
job.cancelledA 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:

HeaderValue
Content-Typeapplication/json
Convertere-Signaturet=<unix seconds>,v1=<hex> (see below)
Convertere-Event-IdThe same evt_… id as the body. It stays the same across retries, so deduplicate on it.
User-AgentConvertere-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 (scope webhook.read) lists them as {"data": [...]} with enabled, disabled_reason and last_success_at. Secrets are never returned again.
  • DELETE /webhooks/{id} removes an endpoint and its delivery history (204).
  • POST /webhooks/{id}/test queues a signed sample job.finished event (with "test": true) and answers 202 with {"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.

On this page