Webhooks
Get a signed POST when a job finishes. Managing subscriptions via the API, HMAC signature verification, retry policy, and payload shape.
Converterer can POST to your endpoint when a conversion task or render job reaches a terminal state, so you don’t have to poll.
When to use webhooks
Use webhooks for anything long-running (video transcodes and large captures can take minutes), for triggering downstream work the moment a job finishes, and to hear about creation_failed or delivery_failed without waiting for your next poll.
When a webhook is sent
A webhook fires when a job’s status moves to one of the terminal states:
deliveredcreation_faileddelivery_failed
In-progress transitions (queued → started) do not fire webhooks. If you need progress reporting, you’ll have to poll.
Setting up a webhook
- Implement a handler at a public URL on your server.
- Create a webhook subscription, either in the dashboard or via the API below. Subscriptions are scoped to a destination, so the API key you authenticate with determines which jobs the subscription sees.
You can create multiple subscriptions if different destinations should point at different endpoints.
Managing subscriptions via the API
Webhook subscriptions can be managed with the same HTTP Basic auth as the rest of the API.
POST /webhooks, create a subscription
| Field | Required | Description |
|---|---|---|
url | yes | The endpoint to POST to. |
name | no | A label for the dashboard. Defaults to the URL’s hostname. |
signing_secret | no | Your own signing secret (up to 255 chars). One is generated if omitted. |
curl -u "$CONVERTERER_API_KEY:" \
https://api.converterer.com/webhooks \
-d "url=https://example.com/hooks/converterer"
Response 201 Created:
{
"id": "hk4wpn2q",
"name": "example.com",
"url": "https://example.com/hooks/converterer",
"signing_secret": "Zt8kQ2mVwYx1cRfLp0aNs6bJdHgE73Ti",
"object": "webhook"
}
Store the signing_secret: you need it to verify signatures.
GET /webhooks, list subscriptions
Returns the subscriptions on the authenticated key’s destination, same shape as above, wrapped in data.
DELETE /webhooks/{id}
Removes a subscription. Returns 204 No Content. Deliveries already queued may still fire once.
What your handler needs to do
- Accept a
POSTrequest with a JSON body. - Return a 2xx response within 5 seconds, otherwise Converterer treats it as a failure and retries later.
- Be idempotent: the same event can be delivered more than once across retries.
A typical pattern is to do the minimum work needed to acknowledge the delivery (validate the payload, push the job ID onto your own queue), then return 200 immediately. Heavy processing happens off the webhook thread.
Retry policy
If your handler returns a non-2xx response or times out:
- Timeout: 5-second connect, 5-second total.
- Backoff: 5s, then 60s, then 600s (10 minutes), then ~1h55m repeating until the cutoff.
- Cutoff: deliveries are retried for up to 48 hours, then dropped.
- Serialized per account: only one webhook delivery per account is in flight at a time, so a slow or stuck handler won’t cause overlapping calls for your other jobs.
After the 48-hour window expires we stop trying. The job stays in its terminal state, so you can always fetch its current status via GET /convert/{id} or GET /jobs/{id}.
Payload
The webhook body is the same shape as the corresponding GET response, with a webhook_id appended.
For a conversion task:
{
"id": "9f1a8e7c-1b9b-4f0a-9d2c-1a2b3c4d5e6f",
"status": "delivered",
"done": true,
"object": "conversion-task",
"file_name": "invoice-4408.pdf",
"url": "https://cdn.example.com/converted/invoice-4408.pdf",
"metadata": {
"order_id": "ord_8817"
},
"webhook_id": 42
}
For a render job:
{
"id": "fe748521-5d8f-43d8-9093-7970d2d032d7",
"url": "https://example.com/report",
"status": "delivered",
"done": true,
"object": "job",
"webhook_id": 42
}
Field notes for conversion tasks:
file_nameis where the output landed in your destination’s storage.urlis a ready-to-use public link to the output. It is present only when the task isdeliveredand the destination can produce one (itspublic_base_urlis set, or it uses public storage). Absent otherwise: fall back to constructing the path fromfile_name.metadataechoes whatever you set on submission, so your handler can route the event without a lookup.webhook_ididentifies the subscription that produced the delivery, useful when several subscriptions point at one endpoint.
For idempotency, key on id + status: the same terminal event can be delivered more than once across retries.
Verifying signatures
Every delivery is signed. The Converterer-Signature header carries a Unix timestamp and an HMAC-SHA256 hex digest:
Converterer-Signature: t=1752940800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The signature is computed over the string {t}.{raw request body} with your subscription’s signing_secret as the key. To verify:
- Split the header on
,, taketandv1. - Concatenate
t, a literal., and the raw request body (before any JSON parsing). - Compute HMAC-SHA256 with your
signing_secretand compare tov1using a constant-time comparison. - Optionally reject if
tis older than a few minutes, to prevent replays.
// Node
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
# Python
import hashlib, hmac
def verify(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
// PHP
function verify(string $header, string $rawBody, string $secret): bool
{
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
Unsigned or badly signed requests to your endpoint should be rejected; anyone on the internet can POST to a public URL.
Locating the result file
The webhook tells you the job finished. If the payload includes url, that link is directly fetchable. Otherwise the file is in your destination’s bucket at file_name (or {id}.{output_format} / {id}.pdf by default): retrieve it from your own storage, the API doesn’t issue signed URLs.
If you need the latest status for a job at any time, hit GET /convert/{id} or GET /jobs/{id}. Same shape as the webhook payload.
Local development
If you’re testing locally, a tunnel like ngrok or Cloudflare Tunnel exposes your dev server at a public URL the webhook subscription can hit. Point the dashboard subscription at the tunnel URL, run your handler locally, and you can iterate without deploying.
After you ship, remember to update the subscription URL to the production endpoint.