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 type | Sent when |
|---|---|
job.succeeded | The job finished and the result is ready. |
job.failed | The job could not be completed. |
job.canceled | The 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.
{
"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" }
}
}
}| Field | Meaning |
|---|---|
id | The event id, starting evt_. The same on every delivery attempt of this event. |
type | One of the three job event types, or ping for a test event. |
created_at | When the job finished. |
livemode | false for a job created with a test key. |
data.job | The job object as it was when the job finished. A ping has an empty data. |
The request carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Smol-Webhooks/1 |
Smol-Event-Id | The event id, the same value as id in the body. |
Smol-Signature | The 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:
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" }'{
"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.
{
"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 to | Signed with | Where you get it |
|---|---|---|
| A registered endpoint | The 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_url | The signing secret of the account. | GET /v1/webhook_endpoints/signing_secret, at any time. |
curl "https://api.smolmac.com/v1/webhook_endpoints/signing_secret" \
-H "Authorization: Bearer $SMOL_API_KEY"{
"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:
Smol-Signature: t=1790846112,v1=9c1f4a7e2b5d8036f1a4c7e0b3d6f9a2c5e8b1d4f7a0c3e6b9d2f5a8c1e4b7d0tis the time the delivery was sent, in seconds since the Unix epoch.v1is 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 thewhsec_prefix.
To verify a delivery:
- Read the raw request body as bytes, before any JSON parsing. A body that has been parsed and serialised again will not match.
- Split the header into
tandv1. - Reject the request if
tis more than five minutes from the current time. This stops a captured request from being replayed later. - Compute the HMAC and compare it with
v1using 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);import hashlib, hmac, json, os, time
from flask import Flask, abort, request
SECRET = os.environ["SMOL_WEBHOOK_SECRET"].encode() # whsec_...
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def is_valid(raw_body: bytes, header: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or not signature:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/smol/webhook")
def smol_webhook():
raw_body = request.get_data() # bytes, exactly as received
if not is_valid(raw_body, request.headers.get("Smol-Signature", "")):
abort(400)
event = json.loads(raw_body)
if event["type"] == "job.succeeded":
# Hand off to a queue or a background task, then answer.
print(event["data"]["job"]["id"], event["data"]["job"]["result"]["download_url"])
return "", 204The 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);import os
from smol_api import verify_webhook
# raw_body: the request body as bytes, exactly as received.
event = verify_webhook(raw_body, request.headers.get("Smol-Signature"), os.environ["SMOL_WEBHOOK_SECRET"])Send a test event
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.
curl -X POST "https://api.smolmac.com/v1/webhook_endpoints/whk_Bn4Zs8QfT2xLc6VmR0jD/test" \
-H "Authorization: Bearer $SMOL_API_KEY"{
"id": "evt_Tq6Wn1YdK8sLc3VbX0mR",
"type": "ping",
"created_at": "2026-10-01T08:05:40.212Z",
"livemode": true,
"data": {}
}{
"object": "webhook_test",
"event_id": "evt_Tq6Wn1YdK8sLc3VbX0mR",
"delivered": true,
"status": 204,
"duration_ms": 182
}deliveredis true when your endpoint answered with a2xxstatus.statusis 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.
datais empty: apingcarries no job. A handler that readsdata.jobwithout checkingtypefirst will fail on it.livemodeis 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_urlhas 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.
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.
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:
| Attempt | Sent |
|---|---|
| 1 | When the job finishes |
| 2 | 10 seconds after attempt 1 failed |
| 3 | 1 minute after attempt 2 failed |
| 4 | 5 minutes after attempt 3 failed |
| 5 | 30 minutes after attempt 4 failed |
| 6 | 2 hours after attempt 5 failed |
| 7 | 6 hours after attempt 6 failed |
| 8 | 12 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
2xxto the rest,pingincluded. - 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, raiseretention_secondson 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.