API reference
Jobs
Create, read, list, cancel and download asynchronous jobs, and fetch their signed receipts.
Overview
A job does the same work as a direct request, but in the background. Use one for a file over 25 MB, for any video, and for any Word document, spreadsheet or presentation. You create the job, then wait for it to finish by polling or by webhook, then download the result.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/jobs | Create a job |
| GET | /v1/jobs/{id} | Retrieve a job |
| GET | /v1/jobs | List jobs |
| DELETE | /v1/jobs/{id} | Cancel a job and delete its files |
| GET | /v1/jobs/{id}/output | Download the output |
| GET | /v1/jobs/{id}/receipt | Get the signed receipt |
| GET | /v1/receipt_keys | Get the public keys that verify receipts |
A job is visible only to keys of the account that created it, and only to keys of the same mode. A test key cannot see a job made with a live key.
Create a job
Send a JSON body of at most 64 KB. A field that is not listed here is refused with bad_request, and param names it.
| Header | Type | Description |
|---|---|---|
| Authorization * | string | Bearer followed by your API key. |
| Content-Type | string | application/json. |
| Idempotency-Key | string | 1 to 255 characters. Makes a retry return the first response without creating a second job. Remembered for 24 hours. See Idempotency. |
| Field | Type | Description |
|---|---|---|
| operation * | string | compress, convert or strip_metadata. |
| input * | object | Where the file comes from. It must have exactly one of upload and url. |
| input.upload | string | The id of an upload whose content has been sent, and which has been completed if it was sent in parts. If no content is found for the id, the request is refused with 404 not_found. |
| input.url | string | A public https URL (see URL rules). The file is fetched with a GET when the job starts. The response must carry a Content-Length header, and the file must be within your plan's job file limit. If the fetch fails, the job is still created, and then fails with input_fetch_failed. Not available with a test key. |
| input.filename | string | The name of the file. Default: the name given to the upload, or the last path segment of the URL. Used to detect the file type and to name the output. |
| options | object | How to process the file. See Options below. For convert, options.convert.to is required. |
| preset | string | The name or id of a preset. Its options are applied first, and the fields in options override them one by one. A preset the account does not have is refused with 400 bad_request. |
| output | object | Send the result to your own storage and do not keep it with us. See Output to your storage. |
| output.url | string | Required when output is present. A public https URL. The result is sent to it with a PUT that carries Content-Type and Content-Length. Redirects are not followed. Any response outside 200 to 299 fails the job with output_upload_failed. |
| output.headers | object | Headers to send with that PUT. Up to 20 entries. Each value is a string of at most 2,000 characters, and each name is at most 100 characters. |
| retention_seconds | integer | How long the output is kept after the job finishes. A whole number from 60 to 86400. Default: your account's setting, which is 3600 unless you changed it. |
| webhook_url | string or null | A public https URL that receives this job's event, in addition to the account's webhook endpoints. The delivery is signed with the account's signing secret. |
| metadata | object | Your own labels. Up to 20 entries. Each value is a string of at most 500 characters, and each key is at most 40 characters. Returned unchanged on the job and in webhooks. |
Options
options has one section per kind of file. Only the section for the kind you sent is used. This object shows every option with its default:
{
"image": {
"format": "original",
"quality": 75,
"resize": { "mode": "fit", "width": 2000, "height": 2000 },
"strip_metadata": true
},
"pdf": { "quality": "medium" },
"video": {
"format": "mp4",
"codec": "h265",
"quality": "high",
"resolution": "original",
"strip_audio": false,
"slow": false,
"crf": null,
"preset": null,
"audio_quality": "high"
},
"audio": {
"format": "aac",
"quality": "high",
"channels": "stereo",
"strip_metadata": false,
"bitrate_kbps": null,
"sample_rate_hz": null
},
"convert": { "to": null, "page": 1, "dpi": 144 }
}image.resizemay befalseto keep the image's dimensions.- Only the five fields shown as
nullacceptnull, which means "use the default". - An unknown section or field, or a value that is not allowed, is refused with
invalid_options. - The image, PDF, audio and convert options are described on the compress and convert pages. Every option is listed in Options.
| Video option | Type | Description |
|---|---|---|
| video.format | string | mp4 (default), mov, webm, mkv. |
| video.codec | string | h265 (default), h264, vp9. WebM output always uses VP9. |
| video.quality | string | tiny, low, balanced, high (default), maximum, web. See video tiers. |
| video.resolution | string | original (default), 3840, 1920, 1280 or 854. The number is a maximum width; the matching maximum heights are 2160, 1080, 720 and 480. The video is only ever scaled down. |
| video.strip_audio | boolean | Default false. true removes the audio track. |
| video.slow | boolean | Default false. true uses the slow encoder preset: a smaller file for more time. |
| video.crf | integer or null | 0 to 63. Replaces the CRF of the quality tier. |
| video.preset | string or null | ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow. Replaces the encoder preset. |
| video.audio_quality | string | tiny, low, balanced, high (default), maximum. The tier of the audio track. |
URL rules
input.url, output.url and webhook_url must each be a public https URL. That means:
- The scheme is
httpsand the port is the default, 443. - There is no user name or password in the URL.
- The host is a public host name.
localhost, single-label names, and names ending in.local,.internal,.lan,.home,.corp,.test,.invalid,.onionor.localhostare refused. - An IPv6 address is refused. An IPv4 address is accepted only if it is public.
A URL that breaks a rule is refused with 400 bad_request. When an input is fetched, up to three redirects are followed, and each hop must meet the same rules.
Response
Status 202 and the job object, with status: "queued". A response replayed for an idempotency key also carries Smol-Idempotent-Replay: true. A repeat that arrives while the first request is still being handled gets 409 idempotency_in_progress with Retry-After: 1.
{
"id": "job_7hQ2mVx9KdL4sTn0BwYe",
"object": "job",
"livemode": true,
"operation": "compress",
"status": "queued",
"created_at": "2026-10-01T12:00:03.118Z",
"started_at": null,
"completed_at": null,
"progress": { "percent": 0 },
"input": { "filename": "report.pdf", "size": 48211930 },
"result": null,
"error": null,
"billed": false,
"expires_at": null,
"files_deleted": false,
"metadata": { "order": "1234" }
}Example
curl -X POST "https://api.smolmac.com/v1/jobs" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2f0e-8f0b-4c53-9d3a-2b7c1c0f5a11" \
-d '{
"operation": "compress",
"input": { "upload": "upl_Zk3q8WcT1nRb5LxYp0Ha" },
"options": { "pdf": { "quality": "small" } },
"metadata": { "order": "1234" }
}'import { randomUUID } from "node:crypto";
const headers = {
Authorization: `Bearer ${process.env.SMOL_API_KEY}`,
"Content-Type": "application/json",
};
const created = await fetch("https://api.smolmac.com/v1/jobs", {
method: "POST",
headers: { ...headers, "Idempotency-Key": randomUUID() },
body: JSON.stringify({
operation: "compress",
input: { upload: "upl_Zk3q8WcT1nRb5LxYp0Ha" },
options: { pdf: { quality: "small" } },
metadata: { order: "1234" },
}),
});
if (!created.ok) throw new Error((await created.json()).error.message);
let job = await created.json();
// Poll until the job reaches a final status.
while (job.status === "queued" || job.status === "processing") {
await new Promise((resolve) => setTimeout(resolve, 2000));
const response = await fetch(`https://api.smolmac.com/v1/jobs/${job.id}`, { headers });
if (!response.ok) throw new Error((await response.json()).error.message);
job = await response.json();
}
if (job.status !== "succeeded") throw new Error(`${job.error.code}: ${job.error.message}`);
console.log(job.result.download_url);import os, time, uuid
import requests
headers = {"Authorization": f"Bearer {os.environ['SMOL_API_KEY']}"}
created = requests.post(
"https://api.smolmac.com/v1/jobs",
headers={**headers, "Idempotency-Key": str(uuid.uuid4())},
json={
"operation": "compress",
"input": {"upload": "upl_Zk3q8WcT1nRb5LxYp0Ha"},
"options": {"pdf": {"quality": "small"}},
"metadata": {"order": "1234"},
},
)
created.raise_for_status()
job = created.json()
# Poll until the job reaches a final status.
while job["status"] in ("queued", "processing"):
time.sleep(2)
response = requests.get(f"https://api.smolmac.com/v1/jobs/{job['id']}", headers=headers)
response.raise_for_status()
job = response.json()
if job["status"] != "succeeded":
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
print(job["result"]["download_url"])The job object
{
"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" }
}| Field | Type | Description |
|---|---|---|
| id | string | The job id: job_ followed by 20 letters and digits. |
| object | string | Always "job". |
| livemode | boolean | true for a job made with a live key, false for a test key. |
| operation | string | compress, convert or strip_metadata. |
| status | string | queued, processing, succeeded, failed or canceled. See Status lifecycle. |
| created_at | string | When the job was created. |
| started_at | string or null | When an engine first accepted the job. null while the job is queued. |
| completed_at | string or null | When the job reached succeeded, failed or canceled. null before that. |
| progress.percent | integer | 0 while queued, 50 while processing, 100 once finished. It is a coarse indicator, not a measure of the work done. |
| input.filename | string or null | The name of the input file, or null if none was given. |
| input.size | integer or null | The size of the input in bytes. null for a URL input that has not been fetched yet. |
| result | object or null | Present when the job succeeded: the result object plus the two fields below. null otherwise. |
| result.download_url | string or null | A signed link to the output. It needs no API key. null when the output went to your own storage, and once the output has been deleted. |
| result.download_expires_at | string or null | When the link stops working. The same time as expires_at. null whenever download_url is null. |
| error | object or null | Present when the job failed or was canceled: type, code and message. The codes are listed in Errors. null otherwise. |
| billed | boolean | true when the job was charged. false until the job finishes, and for failed, canceled, kept-original and test-mode jobs. |
| expires_at | string or null | When the output is deleted: completed_at plus the retention period. null until the job finishes. |
| files_deleted | boolean | true when the input has been deleted and there is no stored output left. |
| metadata | object | The metadata you sent, or an empty object. |
Timestamps are ISO 8601 strings in UTC with milliseconds. Sizes are in bytes.
Status lifecycle
| Status | Meaning | Final |
|---|---|---|
queued | Accepted. A URL input is being fetched, or the job is waiting for a free engine. | No |
processing | An engine is working on the file. | No |
succeeded | The work is done. result is set. This includes a job that returned the input unchanged, with kept_original: true. | Yes |
failed | The work could not be done. error says why. | Yes |
canceled | You sent DELETE before the job finished. error.code is canceled. | Yes |
- The usual path is
queued,processing, then one of the final statuses. A final status never changes. - A job can go from
processingback toqueuedif the engine working on it restarts. It is started again on another engine. After three attempts it fails withinternal. - A job that waits more than 30 minutes for a free engine fails with
timeout. A job that is processed for more than 4 hours fails withtimeout. - When a job reaches a final status, its input is deleted,
billedis set, and a webhook event is sent. - The job record is kept for 30 days after the job finishes. After that, retrieving the job returns
404. Its entry in the list is removed 30 days after the job was created.
Retrieve a job
Returns status 200 and the job object. Returns 404 not_found if there is no such job, if it belongs to another account, if it was made with a key of the other mode, or if its record has been deleted.
curl "https://api.smolmac.com/v1/jobs/job_7hQ2mVx9KdL4sTn0BwYe" \
-H "Authorization: Bearer $SMOL_API_KEY"List jobs
Returns the account's jobs, newest first. A live key lists live jobs. A test key lists test jobs. The list holds the jobs created in the last 30 days.
| Query parameter | Type | Description |
|---|---|---|
| limit | integer | How many jobs to return. 1 to 100. Default 20. A larger number is treated as 100. |
| starting_after | string | A job id. The list continues with the jobs created before that one. An id that is not a job on your account is refused with 400 bad_request. |
| status | string | Return only jobs with this status: queued, processing, succeeded, failed or canceled. |
{
"object": "list",
"data": [
{
"id": "job_7hQ2mVx9KdL4sTn0BwYe",
"object": "job",
"livemode": true,
"operation": "compress",
"status": "succeeded",
"kind": "pdf",
"error_code": null,
"billed": true,
"created_at": "2026-10-01T12:00:03.118Z",
"completed_at": "2026-10-01T12:00:21.950Z"
},
{
"id": "job_Lp3Xc8VbN1mQw6Zt0RdK",
"object": "job",
"livemode": true,
"operation": "convert",
"status": "failed",
"kind": null,
"error_code": "unsupported_target",
"billed": false,
"created_at": "2026-10-01T11:42:50.007Z",
"completed_at": "2026-10-01T11:42:53.611Z"
}
],
"has_more": true
}Each entry is a summary, not the full job object. It has no file name, no result and no download link. Fetch the job by id for those.
| Summary field | Type | Description |
|---|---|---|
| id | string | The job id. |
| object | string | Always "job". |
| livemode | boolean | true for a live job. |
| operation | string | compress, convert or strip_metadata. |
| status | string | The status of the job. |
| kind | string or null | The kind of file, once the job has succeeded: image, pdf, video, audio, word, sheet, presentation, table or font. |
| error_code | string or null | The error code of a failed or canceled job. |
| billed | boolean | true when the job was charged. |
| created_at | string | When the job was created. |
| completed_at | string or null | When the job finished. |
To page through the list, pass the id of the last entry as starting_after, and repeat while has_more is true.
curl "https://api.smolmac.com/v1/jobs?limit=50&status=failed&starting_after=job_Lp3Xc8VbN1mQw6Zt0RdK" \
-H "Authorization: Bearer $SMOL_API_KEY"Cancel or delete a job
What it does depends on the job's status.
- Queued or processing. The work is stopped. The status becomes
canceled, witherror.code: "canceled". Ajob.canceledevent is sent. The job is not billed. - Succeeded, failed or canceled. The status does not change, and no event is sent.
- In every case the input and the stored output are deleted at once, without waiting for
expires_at. The download link stops working.
{
"id": "job_7hQ2mVx9KdL4sTn0BwYe",
"object": "job",
"deleted": true
}deleted refers to the files. The job record stays, so you can still retrieve the job, which now shows files_deleted: true, and fetch its receipt. A file already sent to your own output.url is not affected. Returns 404 not_found under the same conditions as retrieving a job.
curl -X DELETE "https://api.smolmac.com/v1/jobs/job_7hQ2mVx9KdL4sTn0BwYe" \
-H "Authorization: Bearer $SMOL_API_KEY"Download the output
Returns the output file of a job that succeeded. There are two ways to authorise the request.
| Way | How | Use it when |
|---|---|---|
| Signed link | Request result.download_url exactly as given. It carries the query parameters expires (a Unix time in seconds) and signature. Send no API key. | You hand the link to a browser or to another service. |
| API key | Request the path without query parameters, with your Authorization header. The key must be of the job's account and mode. | Your own server downloads the file. |
The response has status 200 and the file as its body.
| Response header | Type | Description |
|---|---|---|
| Content-Type | string | The media type of the output. |
| Content-Length | integer | The size of the output in bytes. |
| Content-Disposition | string | attachment, with the input file name and the extension of the output format. |
| Cache-Control | string | Always private, no-store. |
- A signature that does not match, or a link past its
expirestime, returns403 invalid_signature. 404 not_foundis returned when the job has not succeeded, when the output has been deleted, and when the output was sent to your own storage.- Anyone who has the signed link can download the file until it expires. Treat the link as a secret.
# With the signed link from result.download_url
curl -o report.min.pdf "https://api.smolmac.com/v1/jobs/job_7hQ2mVx9KdL4sTn0BwYe/output?expires=1790859621&signature=5f0c2a9e7b3d4c1f8a6e0d2b4c6f8a1e3d5c7b9a0f2e4d6c8b0a1f3e5d7c9b2a"
# With the API key
curl -o report.min.pdf "https://api.smolmac.com/v1/jobs/job_7hQ2mVx9KdL4sTn0BwYe/output" \
-H "Authorization: Bearer $SMOL_API_KEY"Get the receipt
Returns a signed statement of what was processed and when the files were deleted. It is available once the job has a final status. Before that the request is refused with 409 job_not_finished. See Receipts for how to verify one.
{
"object": "receipt",
"payload": "{\"version\":1,\"job_id\":\"job_7hQ2mVx9KdL4sTn0BwYe\",\"operation\":\"compress\",\"status\":\"succeeded\",\"standard\":\"v1\",\"input_sha256\":\"3b1f0c9a7d52e84f6a0b9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5\",\"input_size\":48211930,\"output_sha256\":\"c41d7e0b95a2f3861b7c4d9e0f2a3b5c6d8e7f90a1b2c3d4e5f60718293a4b6c\",\"output_size\":9310224,\"created_at\":\"2026-10-01T12:00:03.118Z\",\"completed_at\":\"2026-10-01T12:00:21.950Z\",\"input_deleted_at\":\"2026-10-01T12:00:21.958Z\",\"output_deleted_at\":\"2026-10-01T13:00:22.104Z\",\"output_delivered_to_customer_storage\":false,\"issued_at\":\"2026-10-01T14:12:09.331Z\"}",
"signature": "kX9v2mQpL7sT4wZ1aB8cD3eF6gH0jK5nR2uY7xV4bN1mC8qW3eR6tY9uI0oP5aS2dF7gH4jK1lZ8xC3vB6nM0Q==",
"algorithm": "Ed25519",
"key_id": "smol-receipt-1"
}| Field | Type | Description |
|---|---|---|
| object | string | Always "receipt". |
| payload | string | The statement, as a JSON document inside a string. The signature covers this string exactly, byte for byte, so verify it before you parse it. |
| signature | string or null | The Ed25519 signature of the UTF-8 bytes of payload, in base64. null if the receipt could not be signed. |
| algorithm | string | Always "Ed25519". |
| key_id | string or null | Identifies the key that signed the receipt. |
The payload, once parsed, has these fields:
| Payload field | Type | Description |
|---|---|---|
| version | integer | The version of the receipt format. 1. |
| job_id | string | The job this receipt is for. |
| operation | string | compress, convert or strip_metadata. |
| status | string | succeeded, failed or canceled. |
| standard | string | The version of the compression standard that was applied. "v1". |
| input_sha256 | string or null | The SHA-256 of the input, in hex. null if the job did not succeed. |
| input_size | integer or null | The size of the input in bytes. |
| output_sha256 | string or null | The SHA-256 of the output, in hex. null if the job did not succeed. |
| output_size | integer or null | The size of the output in bytes. null if the job did not succeed. |
| created_at | string | When the job was created. |
| completed_at | string | When the job finished. |
| input_deleted_at | string or null | When the input was deleted from our storage. |
| output_deleted_at | string or null | When the output was deleted from our storage. null while it is still stored, and when it was never stored. |
| output_delivered_to_customer_storage | boolean | true when the job had an output.url. |
| issued_at | string | When this receipt was produced. A new receipt is produced on every request. |
To get a receipt that records the deletion of the output, request it after expires_at or after a DELETE. Receipts can be fetched for 30 days after the job finishes.
Receipt keys
Returns the public keys that receipts are signed with. It needs no API key. To verify a receipt, find the entry whose key_id equals the receipt's, and check the receipt's signature against the UTF-8 bytes of its payload with that public key.
{
"object": "list",
"data": [
{
"key_id": "smol-receipt-1",
"algorithm": "Ed25519",
"format": "spki-base64",
"public_key": "MCowBQYDK2VwAyEAq8Jm2Yx0v5cT1nRb7LpKd3WzHs9QeFa4UgXo6ViNtB0=",
"status": "active",
"retired_at": null
}
],
"has_more": false
}| Field | Type | Description |
|---|---|---|
| key_id | string or null | The identifier that appears as key_id in a receipt. |
| algorithm | string | Always "Ed25519". |
| format | string | Always "spki-base64": the public key is a DER SubjectPublicKeyInfo structure, encoded in base64. |
| public_key | string | The public key, in that format. |
| status | string | active for the key that signs new receipts. retired for a key that no longer signs. Retired keys stay listed so that old receipts can still be verified. |
| retired_at | string or null | When a retired key stopped signing. null for the active key. |
The response carries Cache-Control: public, max-age=300. Fetch the keys over HTTPS from api.smolmac.com and store them. Do not take a key from the same place a receipt came from, if that place is not us.
Errors
These are the errors the job endpoints return as HTTP responses. A failure of the work itself is reported on the job, in error. Those codes are listed under job errors.
| Code | Status | Endpoint | When |
|---|---|---|---|
bad_request | 400 | Create, list | The body is not valid JSON or is over 64 KB. A field is unknown, missing or has a value that is not allowed. A URL breaks the URL rules. The preset named does not exist. The Idempotency-Key is empty or over 255 characters. On list: status or starting_after is not valid. |
invalid_options | 400 | Create | An option is unknown or has a value that is not allowed. A convert job has no options.convert.to. |
missing_key, invalid_key, revoked_key | 401 | All | The API key is missing, wrong or revoked. Not checked for a download with a signed link. |
no_payment_method, payment_required | 402 | All | A live key on an account with no plan, or with an unpaid invoice. |
spend_cap_reached | 402 | Create | The account has reached its monthly spend cap. |
test_mode_sample_required | 402 | Create | A test key named an upload that is not a published sample file, or that is over 25 MB, or used input.url. |
account_suspended | 403 | All | The account is suspended. |
ip_not_allowed | 403 | All | The key may not be used from this IP address. |
feature_not_enabled | 403 | Create | The options ask for HEIC output by name, and it is not enabled for the account. |
invalid_signature | 403 | Output | The signed link is wrong or has expired. |
not_found | 404 | All | The job, its output, or the upload named in input.upload does not exist for this account and mode. |
idempotency_conflict | 409 | Create | The Idempotency-Key was already used with a different body. |
idempotency_in_progress | 409 | Create | The first request with this Idempotency-Key is still being handled. Retry-After is 1. |
job_not_finished | 409 | Receipt | The job has no final status yet. |
concurrency_limited | 429 | Create | The account already has its limit of queued and processing jobs. Retry-After is 5. |
internal | 500 | All | Something went wrong on our side. |