Log In Sign Up

Docs

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:

  • delivered
  • creation_failed
  • delivery_failed

In-progress transitions (queuedstarted) do not fire webhooks. If you need progress reporting, you’ll have to poll.

Setting up a webhook

  1. Implement a handler at a public URL on your server.
  2. 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

FieldRequiredDescription
urlyesThe endpoint to POST to.
namenoA label for the dashboard. Defaults to the URL’s hostname.
signing_secretnoYour 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

  1. Accept a POST request with a JSON body.
  2. Return a 2xx response within 5 seconds, otherwise Converterer treats it as a failure and retries later.
  3. 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_name is where the output landed in your destination’s storage.
  • url is a ready-to-use public link to the output. It is present only when the task is delivered and the destination can produce one (its public_base_url is set, or it uses public storage). Absent otherwise: fall back to constructing the path from file_name.
  • metadata echoes whatever you set on submission, so your handler can route the event without a lookup.
  • webhook_id identifies 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:

  1. Split the header on ,, take t and v1.
  2. Concatenate t, a literal ., and the raw request body (before any JSON parsing).
  3. Compute HMAC-SHA256 with your signing_secret and compare to v1 using a constant-time comparison.
  4. Optionally reject if t is 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.