Documentation menu

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.

json
{
  "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.

FieldTypeDescription
idstringThe event id: evt_ followed by 20 letters and digits. It stays the same across the retries of one delivery.
typestringjob.succeeded, job.failed, job.canceled or ping.
created_atstringFor a job event: when the job finished, the same value as the job's completed_at. For a ping: when the test was sent.
livemodebooleantrue 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.
dataobjectWhat the event is about. An empty object for a ping.
data.jobobjectOn job events: the job object as it was at the moment the job finished. Absent on a ping.

Event types

TypeSent whenIn data.job
job.succeededThe work is done.result is set. error is null.
job.failedThe work could not be done.error is set. result is null.
job.canceledYou sent DELETE /v1/jobs/{id} before the job finished.error.code is canceled. result is null.
pingYou 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.

job.succeeded
{
  "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.

job.failed
{
  "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

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.

ping
{
  "id": "evt_Tq6Wn1YdK8sLc3VbX0mR",
  "type": "ping",
  "created_at": "2026-10-01T12:20:40.212Z",
  "livemode": true,
  "data": {}
}

Delivery headers

HeaderTypeDescription
Content-Typestringapplication/json.
User-AgentstringSmol-Webhooks/1.
Smol-Event-IdstringThe event id, the same as id in the body. Use it to recognise a delivery you have already handled.
Smol-Signaturestringt=<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.

what is signed
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 t is 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);
}

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.