API reference
Webhook events
The envelope, the three job event types and the ping test event, an example payload for each, and how a delivery is signed and retried.
When an event is sent
One event is sent each time a job reaches a final status: succeeded, failed or canceled. Direct requests send none. The only other event is ping, which is sent when you ask for a test.
The event goes to:
- every enabled webhook endpoint of the account, and
- the job's
webhook_url, if it has one and that URL is not already one of the endpoints.
Each destination gets its own delivery, with its own event id. See Webhooks for a walk-through of receiving one.
The event envelope
A delivery is an HTTP POST with a JSON body.
{
"id": "evt_Xw3Nc7RtK0pLm5Zq9VdB",
"type": "job.succeeded",
"created_at": "2026-10-01T12:00:21.950Z",
"livemode": true,
"data": {
"job": { "id": "job_7hQ2mVx9KdL4sTn0BwYe", "object": "job", "status": "succeeded" }
}
}The job is shortened here. A real event carries the whole job object, as in the examples below.
| Field | Type | Description |
|---|---|---|
| id | string | The event id: evt_ followed by 20 letters and digits. It stays the same across the retries of one delivery. |
| type | string | job.succeeded, job.failed, job.canceled or ping. |
| created_at | string | For a job event: when the job finished, the same value as the job's completed_at. For a ping: when the test was sent. |
| livemode | boolean | true for a job made with a live key, false for a test key. For a ping: the mode of the key that asked for the test. |
| data | object | What the event is about. An empty object for a ping. |
| data.job | object | On job events: the job object as it was at the moment the job finished. Absent on a ping. |
Event types
| Type | Sent when | In data.job |
|---|---|---|
job.succeeded | The work is done. | result is set. error is null. |
job.failed | The work could not be done. | error is set. result is null. |
job.canceled | You sent DELETE /v1/jobs/{id} before the job finished. | error.code is canceled. result is null. |
ping | You sent POST /v1/webhook_endpoints/{id}/test. | Nothing. data is an empty object. |
job.succeeded
result.download_url is ready to use. It is null if the job sent its output to your own storage. A job that returned the input unchanged is also a success, with result.kept_original: true and billed: false.
{
"id": "evt_Xw3Nc7RtK0pLm5Zq9VdB",
"type": "job.succeeded",
"created_at": "2026-10-01T12:00:21.950Z",
"livemode": true,
"data": {
"job": {
"id": "job_7hQ2mVx9KdL4sTn0BwYe",
"object": "job",
"livemode": true,
"operation": "compress",
"status": "succeeded",
"created_at": "2026-10-01T12:00:03.118Z",
"started_at": "2026-10-01T12:00:04.402Z",
"completed_at": "2026-10-01T12:00:21.950Z",
"progress": { "percent": 100 },
"input": { "filename": "report.pdf", "size": 48211930 },
"result": {
"kind": "pdf",
"input_format": "pdf",
"output_format": "pdf",
"original_size": 48211930,
"output_size": 9310224,
"savings_percent": 80.7,
"kept_original": false,
"increased": false,
"width": null,
"height": null,
"pages": 212,
"duration_seconds": null,
"video_codec": null,
"warnings": [],
"download_url": "https://api.smolmac.com/v1/jobs/job_7hQ2mVx9KdL4sTn0BwYe/output?expires=1790859621&signature=5f0c2a9e7b3d4c1f8a6e0d2b4c6f8a1e3d5c7b9a0f2e4d6c8b0a1f3e5d7c9b2a",
"download_expires_at": "2026-10-01T13:00:21.950Z"
},
"error": null,
"billed": true,
"expires_at": "2026-10-01T13:00:21.950Z",
"files_deleted": false,
"metadata": { "order": "1234" }
}
}
}job.failed
error has type, code and message. The codes are listed under job errors. The input has already been deleted, so files_deleted is true. A failed job is not billed.
{
"id": "evt_Gd8Ls2VnQ4xTc6Yb1WmP",
"type": "job.failed",
"created_at": "2026-10-01T12:07:44.310Z",
"livemode": true,
"data": {
"job": {
"id": "job_Lp3Xc8VbN1mQw6Zt0RdK",
"object": "job",
"livemode": true,
"operation": "compress",
"status": "failed",
"created_at": "2026-10-01T12:07:41.026Z",
"started_at": "2026-10-01T12:07:42.190Z",
"completed_at": "2026-10-01T12:07:44.310Z",
"progress": { "percent": 100 },
"input": { "filename": "contract.pdf", "size": 3120448 },
"result": null,
"error": {
"type": "processing_error",
"code": "encrypted_pdf",
"message": "This PDF is password protected. Remove the password and try again."
},
"billed": false,
"expires_at": "2026-10-01T13:07:44.310Z",
"files_deleted": true,
"metadata": {}
}
}
}job.canceled
{
"id": "evt_Jq5Zb0WtH7nRk2Cx8MvD",
"type": "job.canceled",
"created_at": "2026-10-01T12:15:09.774Z",
"livemode": true,
"data": {
"job": {
"id": "job_Yt6Kc1PdS9gFw3Nz4QhA",
"object": "job",
"livemode": true,
"operation": "convert",
"status": "canceled",
"created_at": "2026-10-01T12:14:30.502Z",
"started_at": "2026-10-01T12:14:31.660Z",
"completed_at": "2026-10-01T12:15:09.774Z",
"progress": { "percent": 100 },
"input": { "filename": "talk.mov", "size": 91226112 },
"result": null,
"error": {
"type": "invalid_request_error",
"code": "canceled",
"message": "The job was canceled."
},
"billed": false,
"expires_at": "2026-10-01T13:15:09.774Z",
"files_deleted": true,
"metadata": {}
}
}
}ping
A test event. It is sent only by POST /v1/webhook_endpoints/{id}/test, to that one endpoint, once, with no retry. It has the same headers and the same signature scheme as a job event, so a receiver that verifies a ping correctly will verify job events correctly. data is empty: there is no job.
{
"id": "evt_Tq6Wn1YdK8sLc3VbX0mR",
"type": "ping",
"created_at": "2026-10-01T12:20:40.212Z",
"livemode": true,
"data": {}
}Delivery headers
| Header | Type | Description |
|---|---|---|
| Content-Type | string | application/json. |
| User-Agent | string | Smol-Webhooks/1. |
| Smol-Event-Id | string | The event id, the same as id in the body. Use it to recognise a delivery you have already handled. |
| Smol-Signature | string | t=<unix seconds>,v1=<hex>. Proves the delivery came from Smol. See below. |
The signature
Smol-Signature has two parts separated by a comma. t is the time the delivery was sent, in Unix seconds. v1 is an HMAC-SHA256, in lower-case hex, of the string made of t, a dot, and the raw request body. The key is the secret of the destination.
Smol-Signature: t=1790856021,v1=<64 hex characters>
v1 = HMAC-SHA256(secret, "1790856021" + "." + raw request body)To verify a delivery:
- Read the raw body as bytes, before any JSON parsing. A re-serialised body will not match.
- Compute the HMAC with the right secret: the endpoint's secret for a registered endpoint, the account signing secret for a job's
webhook_url. See which secret signs what. After a rotation, accept the old and the new secret until retries of earlier events have run out. - Compare it with v1 using a constant-time comparison.
- Reject a delivery whose
tis more than five minutes old, so that a captured request cannot be replayed later. Every attempt is signed afresh with the current time.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body as a string or Buffer, exactly as received.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "utf8");
const wanted = Buffer.from(expected, "utf8");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
return fresh && given.length === wanted.length && timingSafeEqual(given, wanted);
}import hashlib, hmac, time
# raw_body: the request body as bytes, exactly as received.
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) <= 300
return fresh and hmac.compare_digest(expected, parts.get("v1", ""))Responses and retries
- Success is any response with a status from 200 to 299. The delivery is then finished.
- Failure is any other status, no response within 10 seconds, or a connection that cannot be made. A redirect is not followed and counts as a failure.
- Retries. A failed delivery of a job event is sent again after 10 seconds, then 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. That is eight attempts over about 21 hours. After the last one the delivery is dropped. A ping is never retried.
- Respond quickly. Return a 2xx as soon as you have stored the event, and do slow work afterwards. The response body is ignored.
- Expect duplicates. If your 2xx is lost on the way back, the same event is sent again. Keep the ids you have handled and skip one you have seen.
- Do not rely on order. Events for different jobs can arrive in any order.
If you miss an event, nothing is lost: GET /v1/jobs/{id} and GET /v1/jobs always show the current state of a job.