Documentation menu

Guides

Webhooks

Have the API call your server when a job finishes, and verify that each call really came from Smol.

Events

A webhook is an HTTPS POST from the API to a URL on your server. It is sent when a job reaches a final status, so you do not have to poll.

Event typeSent when
job.succeededThe job finished and the result is ready.
job.failedThe job could not be completed.
job.canceledThe job was deleted before it finished.

Only jobs produce these events. Direct requests such as POST /v1/compress return their result in the response and send nothing. There is one more type, ping, which is sent only when you ask for a test event. Have your handler ignore any type it does not know, so that a new type does not break it. Every event type is described in Webhook events.

The payload

The request body is JSON. The job is included in full, so most handlers need no further API call.

job.succeeded
{
  "id": "evt_Pw7Dk2SxN5bGq0YtH9cL",
  "type": "job.succeeded",
  "created_at": "2026-10-01T09:15:12.870Z",
  "livemode": true,
  "data": {
    "job": {
      "id": "job_8Hq2LmZx4TnV0cRb7KpW",
      "object": "job",
      "livemode": true,
      "operation": "compress",
      "status": "succeeded",
      "created_at": "2026-10-01T09:14:31.120Z",
      "started_at": "2026-10-01T09:14:32.480Z",
      "completed_at": "2026-10-01T09:15:12.870Z",
      "progress": { "percent": 100 },
      "input": { "filename": "report.pdf", "size": 64182301 },
      "result": {
        "kind": "pdf",
        "input_format": "pdf",
        "output_format": "pdf",
        "original_size": 64182301,
        "output_size": 9627345,
        "savings_percent": 85.0,
        "kept_original": false,
        "increased": false,
        "width": null,
        "height": null,
        "pages": 412,
        "duration_seconds": null,
        "video_codec": null,
        "warnings": [],
        "download_url": "https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW/output?expires=1790849712&signature=3f9a1c7e5b2d8046a1c3e5f7092b4d6e8f0a1c3e5b7d9f1a2c4e6f8091b3d5e7",
        "download_expires_at": "2026-10-01T10:15:12.870Z"
      },
      "error": null,
      "billed": true,
      "expires_at": "2026-10-01T10:15:12.870Z",
      "files_deleted": false,
      "metadata": { "document_id": "doc_1234" }
    }
  }
}
FieldMeaning
idThe event id, starting evt_. The same on every delivery attempt of this event.
typeOne of the three job event types, or ping for a test event.
created_atWhen the job finished.
livemodefalse for a job created with a test key.
data.jobThe job object as it was when the job finished. A ping has an empty data.

The request carries these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSmol-Webhooks/1
Smol-Event-IdThe event id, the same value as id in the body.
Smol-SignatureThe timestamp and signature of this delivery. See below.

Two ways to receive events

Register an endpoint for the account

A registered endpoint receives the events of every job on the account, test and live. Register one in the dashboard, or with the API:

POST/v1/webhook_endpoints
bash
curl -X POST "https://api.smolmac.com/v1/webhook_endpoints" \
  -H "Authorization: Bearer $SMOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/smol/webhook" }'
201 response
{
  "id": "whk_Bn4Zs8QfT2xLc6VmR0jD",
  "object": "webhook_endpoint",
  "url": "https://example.com/smol/webhook",
  "enabled": true,
  "created_at": "2026-10-01T08:02:11.530Z",
  "secret": "whsec_..."
}

An account can have up to 10 endpoints. GET /v1/webhook_endpoints lists them, PATCH /v1/webhook_endpoints/{id} pauses or resumes one, and DELETE /v1/webhook_endpoints/{id} removes one. See the webhook endpoints reference.

Name a URL on one job

Set webhook_url when you create a job, and that URL is notified when that job finishes. Nothing has to be registered first.

POST /v1/jobs body
{
  "operation": "compress",
  "input": { "upload": "upl_Zk3Vb9QeT1mXc7HsW0yN" },
  "webhook_url": "https://example.com/smol/webhook"
}

The per-job URL is an addition, not a replacement. Registered endpoints still receive the event. If the per-job URL is the same as a registered endpoint, the event is delivered to it once.

In both cases the URL must use https on the default port, with a public host name and no user name or password in it. Anything else is refused with 400 bad_request.

Signing secrets

Every delivery is signed with a secret that starts whsec_. Which secret depends on how the URL was given:

Delivery toSigned withWhere you get it
A registered endpointThe secret of that endpoint. Each endpoint has its own.The secret field of the response that created the endpoint, or of the response that rotated its secret. It is shown once and cannot be fetched again.
A per-job webhook_urlThe signing secret of the account.GET /v1/webhook_endpoints/signing_secret, at any time.
bash
curl "https://api.smolmac.com/v1/webhook_endpoints/signing_secret" \
  -H "Authorization: Bearer $SMOL_API_KEY"
200 response
{
  "object": "webhook_signing_secret",
  "secret": "whsec_..."
}

Store the secret with your other credentials. If you lose the secret of a registered endpoint, rotate it to get a new one.

Verify the signature

Your webhook URL is public, so anyone can post to it. The signature is how you know a request came from Smol and was not altered. The Smol-Signature header has two parts:

header
Smol-Signature: t=1790846112,v1=9c1f4a7e2b5d8036f1a4c7e0b3d6f9a2c5e8b1d4f7a0c3e6b9d2f5a8c1e4b7d0
  • t is the time the delivery was sent, in seconds since the Unix epoch.
  • v1 is the HMAC-SHA256, in lowercase hex, of the string <t>.<raw body>: the timestamp, a full stop, then the request body exactly as it was received. The key is the whole secret, including the whsec_ prefix.

To verify a delivery:

  1. Read the raw request body as bytes, before any JSON parsing. A body that has been parsed and serialised again will not match.
  2. Split the header into t and v1.
  3. Reject the request if t is more than five minutes from the current time. This stops a captured request from being replayed later.
  4. Compute the HMAC and compare it with v1 using a constant-time comparison.
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.SMOL_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

function isValid(rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// express.raw keeps the body as a Buffer, which is what the signature covers.
app.post("/smol/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!isValid(req.body, req.get("Smol-Signature") ?? "")) {
    return res.status(400).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.type === "job.succeeded") {
    // Hand off to a queue or a background task, then answer.
    console.log(event.data.job.id, event.data.job.result.download_url);
  }
  res.status(204).end();
});

app.listen(3000);

The SDKs do the same check in one call. Both parse the event and return it, and both raise an error if the signature is missing, wrong, or more than five minutes old.

import { Smol } from "@smolmac/api";

const smol = new Smol({ apiKey: process.env.SMOL_API_KEY });

// rawBody: the request body as a string, exactly as received.
const event = await smol.webhooks.verify(rawBody, request.headers.get("smol-signature"), process.env.SMOL_WEBHOOK_SECRET);

Send a test event

POST/v1/webhook_endpoints/{id}/test

To check your handler without running a job, ask for a test event. The API sends one event of type ping to the endpoint, signed with the secret of that endpoint, and tells you what the endpoint answered.

bash
curl -X POST "https://api.smolmac.com/v1/webhook_endpoints/whk_Bn4Zs8QfT2xLc6VmR0jD/test" \
  -H "Authorization: Bearer $SMOL_API_KEY"
what your endpoint receives
{
  "id": "evt_Tq6Wn1YdK8sLc3VbX0mR",
  "type": "ping",
  "created_at": "2026-10-01T08:05:40.212Z",
  "livemode": true,
  "data": {}
}
200 response to your request
{
  "object": "webhook_test",
  "event_id": "evt_Tq6Wn1YdK8sLc3VbX0mR",
  "delivered": true,
  "status": 204,
  "duration_ms": 182
}
  • delivered is true when your endpoint answered with a 2xx status. status is the status it returned, or 0 if it did not answer within 10 seconds or could not be reached.
  • The test event is sent once. It is not retried.
  • data is empty: a ping carries no job. A handler that reads data.job without checking type first will fail on it.
  • livemode is the mode of the key that asked for the test. A paused endpoint can be tested too.
  • Only registered endpoints can be tested this way. A per-job webhook_url has no test call.

Pause an endpoint, rotate a secret

Pause. PATCH /v1/webhook_endpoints/{id} with { "enabled": false } stops deliveries to an endpoint without deleting it. The endpoint keeps its secret. Events for jobs that finish while it is paused are not sent to it later. Send { "enabled": true } to resume.

bash
curl -X PATCH "https://api.smolmac.com/v1/webhook_endpoints/whk_Bn4Zs8QfT2xLc6VmR0jD" \
  -H "Authorization: Bearer $SMOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

Rotate. POST /v1/webhook_endpoints/{id}/rotate_secret gives the endpoint a new secret and returns it once, in the secret field. Events for jobs that finish after the call are signed with the new secret.

bash
curl -X POST "https://api.smolmac.com/v1/webhook_endpoints/whk_Bn4Zs8QfT2xLc6VmR0jD/rotate_secret" \
  -H "Authorization: Bearer $SMOL_API_KEY"

Retries

A delivery succeeds when your server answers with a 2xx status within 10 seconds. Anything else counts as a failure: another status, a timeout, a connection error. Redirects are not followed, so a 3xx answer is a failure too.

After a failure the same event is sent again, with a growing delay:

AttemptSent
1When the job finishes
210 seconds after attempt 1 failed
31 minute after attempt 2 failed
45 minutes after attempt 3 failed
530 minutes after attempt 4 failed
62 hours after attempt 5 failed
76 hours after attempt 6 failed
812 hours after attempt 7 failed

That is eight attempts over about 20 and a half hours. If the eighth fails, the event is dropped and is not sent again. Each endpoint is retried on its own schedule: a failure at one endpoint does not delay or repeat the delivery to another.

Every attempt of an event has the same body and the same Smol-Event-Id. Only the Smol-Signature header changes, because it contains the time of the attempt.

Handling events well

  • Answer fast. Verify the signature, store or queue the event, and return 2xx. Do the download and any slow work afterwards. An answer that takes more than 10 seconds is treated as a failure and the event is sent again.
  • Expect duplicates. If your answer is lost on the way back, the event is delivered again. Record the event id and skip ids you have already handled.
  • Check livemode. Registered endpoints receive events for test jobs too.
  • Switch on the type. Act on the types you know and answer 2xx to the rest, ping included.
  • Mind the download window. The result of a job is deleted at expires_at, one hour after the job finished by default. Retries can arrive later than that. If your endpoint may be down for a while, raise retention_seconds on the job, or deliver the result to your own storage.
  • Keep polling as a fallback. If an event was dropped after the last retry, the job is still there. GET /v1/jobs/{id} returns its final state for 30 days.