Guides
Large files and jobs
Process files over 25 MB, and all video and office documents, with the asynchronous flow: upload in one request or in parts, create a job, wait, download.
When to use a job
A direct request such as POST /v1/compress holds the connection open until the result is ready. That works for files of up to 25 MB that finish within two minutes. Everything else runs as a job:
- files over 25 MB, which a direct request refuses with
413 file_too_large; - all video and all office documents, which a direct request refuses with
415 use_async_job; - anything you would rather not wait for on an open connection.
A job takes the same three operations and the same options as the direct endpoints. The difference is that the result is stored for a short time, so you can fetch it when the job is done.
1. Upload the file
First declare the upload. size is the size of the file in bytes and is required. filename is optional, but send it: it becomes the name of the result, and some formats are recognised by it.
curl -X POST "https://api.smolmac.com/v1/uploads" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "report.pdf", "size": 64182301 }'{
"id": "upl_Zk3Vb9QeT1mXc7HsW0yN",
"object": "upload",
"size": 64182301,
"method": "PUT",
"headers": {
"authorization": "Bearer <your API key>",
"content-type": "application/octet-stream"
},
"expires_at": "2026-10-02T09:14:02.000Z",
"multipart": false,
"url": "https://api.smolmac.com/v1/uploads/upl_Zk3Vb9QeT1mXc7HsW0yN/content?filename=report.pdf"
}multipart says how to send the bytes. It is false for a file of up to 95 MB (99,614,720 bytes), which goes in one request. For a larger file it is true, and the file goes in parts.
Send the bytes to url with the method and headers the response lists. The request needs your API key and a Content-Length header. Uploads go through the API, not to a storage address, so the key and its IP allow-list apply to them too.
curl -X PUT "https://api.smolmac.com/v1/uploads/upl_Zk3Vb9QeT1mXc7HsW0yN/content?filename=report.pdf" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/octet-stream" \
--upload-file report.pdf{
"id": "upl_Zk3Vb9QeT1mXc7HsW0yN",
"object": "upload",
"size": 64182301,
"uploaded": true
}expires_at is 24 hours after the upload was created. An upload that no job has used by then is deleted. Create the job before that.
Files over 95 MB: upload in parts
One request to the API carries at most 95 MB. For a larger file, POST /v1/uploads answers with a plan: the file cut into parts of 64 MB (67,108,864 bytes), with a last part that holds the rest. The file can be as large as the job file limit of your plan allows. See Limits.
{
"id": "upl_M2xVq7TnLc0RbY5sKd9W",
"object": "upload",
"size": 262144000,
"method": "PUT",
"headers": {
"authorization": "Bearer <your API key>",
"content-type": "application/octet-stream"
},
"expires_at": "2026-10-02T09:14:02.000Z",
"multipart": true,
"part_size": 67108864,
"parts": [
{ "part_number": 1, "size": 67108864, "url": "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/parts/1" },
{ "part_number": 2, "size": 67108864, "url": "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/parts/2" },
{ "part_number": 3, "size": 67108864, "url": "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/parts/3" },
{ "part_number": 4, "size": 60817408, "url": "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/parts/4" }
],
"complete_url": "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/complete"
}- Send each part.
PUTthe bytes of partnto itsurl. Partnstarts at byte(n − 1) × part_sizeof the file and is exactlysizebytes long. A request with any other length is refused with400 bad_request. - Complete the upload.
POSTtocomplete_urlwith no body. The parts are joined into one file, and the upload is ready for a job.
# Part 2: skip the first 67108864 bytes, then send the next 67108864.
tail -c +67108865 talk.mov | head -c 67108864 | \
curl -X PUT "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/parts/2" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @-
# When every part has been sent:
curl -X POST "https://api.smolmac.com/v1/uploads/upl_M2xVq7TnLc0RbY5sKd9W/complete" \
-H "Authorization: Bearer $SMOL_API_KEY"- Parts may be sent in any order, and several at once.
- Sending a part again replaces it. If one part fails, send that part again. The others are kept, and you do not collect anything from the part responses: the API remembers which parts it has.
- Completing with parts missing is refused with
409 upload_incomplete, and the message lists the part numbers still to send. Completing twice, or sending a part after completion, is refused with409 upload_already_completed. - A job cannot use the upload before it is completed.
POST /v1/jobsanswers404 not_founduntil then. - An upload in parts that is not completed within 24 hours is discarded.
Using a URL instead of an upload
If the file is already in your own storage, the job can fetch it. Give the job a URL that returns the file to an unauthenticated GET, for example a presigned download link from your bucket. The URL must:
- use https on the default port, with a public host name and no user name or password in it;
- answer with status 200 and a
Content-Lengthheader; - reach the file within three redirects;
- serve a file no larger than the file size limit of your plan.
A URL that breaks the first rule is refused when the job is created, with 400 bad_request and param set to input.url. The others are checked when the job fetches the file. If the fetch fails, the job fails with the error code input_fetch_failed. A test key cannot use input.url.
2. Create a job
curl -X POST "https://api.smolmac.com/v1/jobs" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"operation": "compress",
"input": { "upload": "upl_Zk3Vb9QeT1mXc7HsW0yN" },
"options": { "pdf": { "quality": "small" } },
"metadata": { "document_id": "doc_1234" }
}'| Field | Type | Description |
|---|---|---|
| operation * | string | compress, convert or strip_metadata. |
| input * | object | Exactly one of upload (the id of an upload whose content has been sent) or url (an https URL to fetch). An optional filename overrides the name of the file. |
| options | object | The options object. 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. |
| output | object | Deliver the result to your own storage instead of ours. See Output to your storage. |
| retention_seconds | integer | How long the result is kept after the job finishes, 60 to 86400. See Retention. |
| webhook_url | string | An https URL to notify when this job finishes. See Webhooks. |
| metadata | object | Your own key and value strings, returned with the job. See Metadata. |
A field that is not in this list is refused with 400 bad_request, and param names it. The response is 202 with the job object. The job has been accepted, not finished:
{
"id": "job_8Hq2LmZx4TnV0cRb7KpW",
"object": "job",
"livemode": true,
"operation": "compress",
"status": "queued",
"created_at": "2026-10-01T09:14:31.120Z",
"started_at": null,
"completed_at": null,
"progress": { "percent": 0 },
"input": { "filename": "report.pdf", "size": 64182301 },
"result": null,
"error": null,
"billed": false,
"expires_at": null,
"files_deleted": false,
"metadata": { "document_id": "doc_1234" }
}An upload serves one job. The uploaded file is deleted as soon as that job finishes, whatever the outcome. To process the same file again with other options, upload it again.
Each plan allows a number of jobs to be queued or running at once: 3 on pay as you go, 10 on Starter, 30 on Growth, 100 on Scale. Beyond that, POST /v1/jobs answers 429 concurrency_limited with Retry-After: 5. Wait for a job to finish, then send the request again. Creating a job does not count against the requests-per-second rate limit, which applies to direct requests.
3. Wait for the job
Fetch the job to see where it is. Poll every few seconds until status is one of the three final values.
curl "https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW" \
-H "Authorization: Bearer $SMOL_API_KEY"| status | Meaning | Final? |
|---|---|---|
queued | Accepted. Waiting for the input to be fetched or for capacity. | No |
processing | The file is being processed. | No |
succeeded | Done. result is filled in. | Yes |
failed | Not done. error says why. Not billed. | Yes |
canceled | You deleted the job before it finished. Not billed. | Yes |
progress.percent is a coarse indicator: 0 while queued, 50 while processing, 100 when final. It is not a measure of how much of the file has been processed.
Polling is the simplest way to wait. For many jobs, or long ones, have the API call you instead: see Webhooks. To list jobs, use GET /v1/jobs, which is described in the jobs reference.
4. Download the result
When the job has succeeded, result.download_url holds a link to the output file.
curl -o report.min.pdf \
"https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW/output?expires=1790849712&signature=3f9a1c7e5b2d8046a1c3e5f7092b4d6e8f0a1c3e5b7d9f1a2c4e6f8091b3d5e7"- The link is signed and needs no API key. You can hand it to a browser or to another service without exposing your key. Anyone who has the link can download the file until it expires, so treat it as a secret.
- It works until
result.download_expires_at, which is when the file is deleted. After that it answers403 invalid_signature. - The response has the content type of the output and a
Content-Dispositionheader with the name of the input and the extension of the output.
From your own server you can also call GET /v1/jobs/{id}/output with your API key and no signature. It returns the same file.
Full example
With an SDK the four steps are one call. It uploads in parts when the file is over 95 MB:
import { readFile, writeFile } from "node:fs/promises";
import { Smol } from "@smolmac/api";
const smol = new Smol({ apiKey: process.env.SMOL_API_KEY });
const { job, data } = await smol.run("compress", await readFile("report.pdf"), {
filename: "report.pdf",
options: { pdf: { quality: "small" } },
});
await writeFile("report.min.pdf", data);
console.log(job.result.savings_percent, "% smaller");import os
from smol_api import Smol
smol = Smol(api_key=os.environ["SMOL_API_KEY"])
with open("report.pdf", "rb") as f:
job, data = smol.run("compress", f.read(), filename="report.pdf", options={"pdf": {"quality": "small"}})
with open("report.min.pdf", "wb") as out:
out.write(data)
print(job["result"]["savings_percent"], "% smaller")The same over plain HTTP, step by step: upload, create the job, poll, download. This file is under 95 MB, so the upload is one request. Code that also handles uploads in parts is in the uploads reference.
import { readFile, writeFile } from "node:fs/promises";
const API = "https://api.smolmac.com";
const auth = { Authorization: `Bearer ${process.env.SMOL_API_KEY}` };
async function api(method, path, body) {
const response = await fetch(API + path, {
method,
headers: { ...auth, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const json = await response.json();
if (!response.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json;
}
const file = await readFile("report.pdf");
// 1. Declare the upload, then send the bytes to its URL.
const upload = await api("POST", "/v1/uploads", { filename: "report.pdf", size: file.byteLength });
const put = await fetch(upload.url, {
method: "PUT",
headers: { ...auth, "Content-Type": "application/octet-stream" },
body: file,
});
if (!put.ok) throw new Error((await put.json()).error.message);
// 2. Create the job.
let job = await api("POST", "/v1/jobs", {
operation: "compress",
input: { upload: upload.id },
options: { pdf: { quality: "small" } },
});
// 3. Poll until the status is final.
while (job.status === "queued" || job.status === "processing") {
await new Promise((resolve) => setTimeout(resolve, 2000));
job = await api("GET", `/v1/jobs/${job.id}`);
}
if (job.status !== "succeeded") throw new Error(`${job.error.code}: ${job.error.message}`);
// 4. Download. The link is signed, so no key is sent.
const download = await fetch(job.result.download_url);
if (!download.ok) throw new Error(`Download failed with status ${download.status}`);
await writeFile("report.min.pdf", Buffer.from(await download.arrayBuffer()));
console.log(job.result.savings_percent, "% smaller");import os, time, requests
API = "https://api.smolmac.com"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SMOL_API_KEY']}"
def check(response):
if not response.ok:
error = response.json()["error"]
raise RuntimeError(f"{error['code']}: {error['message']}")
return response.json()
path = "report.pdf"
# 1. Declare the upload, then send the bytes to its URL.
upload = check(session.post(f"{API}/v1/uploads", json={"filename": path, "size": os.path.getsize(path)}))
with open(path, "rb") as f:
check(session.put(upload["url"], data=f, headers={"Content-Type": "application/octet-stream"}))
# 2. Create the job.
job = check(session.post(f"{API}/v1/jobs", json={
"operation": "compress",
"input": {"upload": upload["id"]},
"options": {"pdf": {"quality": "small"}},
}))
# 3. Poll until the status is final.
while job["status"] in ("queued", "processing"):
time.sleep(2)
job = check(session.get(f"{API}/v1/jobs/{job['id']}"))
if job["status"] != "succeeded":
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
# 4. Download. The link is signed, so no key is sent.
with requests.get(job["result"]["download_url"], stream=True) as download:
download.raise_for_status()
with open("report.min.pdf", "wb") as out:
for chunk in download.iter_content(chunk_size=1 << 20):
out.write(chunk)
print(job["result"]["savings_percent"], "% smaller")The job object
This is the job from the example once it has succeeded:
{
"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 | Type | Description |
|---|---|---|
| id | string | The job id, starting job_. |
| livemode | boolean | true for a job created with a live key, false for a test key. A key only sees jobs of its own mode. |
| operation | string | The operation the job was created with. |
| status | string | One of the five statuses above. |
| created_at, started_at, completed_at | string or null | ISO 8601 times in UTC. The last two are null until the job reaches that point. |
| progress.percent | integer | 0, 50 or 100. |
| input | object | filename and size in bytes. For a URL input, size is null until the file has been fetched. |
| result | object or null | Null until the job succeeds. Then the result object, plus download_url and download_expires_at. Both are null once the output has been deleted, and when the output went to your own storage. |
| error | object or null | Null unless the job failed or was canceled. Then type, code and message. |
| billed | boolean | Whether the job was charged. |
| expires_at | string or null | When the output is deleted. Null until the job is final. |
| files_deleted | boolean | True when neither the input nor the output is stored by us any more. |
| metadata | object | The metadata you sent, or an empty object. |
The record of a job, without its files, stays readable for 30 days after the job finishes. After that GET /v1/jobs/{id} answers 404 not_found.
Failed jobs
A job that cannot be completed ends with the status failed and an error object. The HTTP status of GET /v1/jobs/{id} is still 200: the request for the job worked, the job did not. A failed job is never billed.
{
"id": "job_Xr5Tb1NcW9hKq3LmV7dZ",
"object": "job",
"status": "failed",
"result": null,
"error": {
"type": "processing_error",
"code": "encrypted_pdf",
"message": "This PDF is password protected. Remove the password and try again."
},
"billed": false,
"files_deleted": true
}| error.code | Cause |
|---|---|
unsupported_input, unsupported_target, invalid_options | The file or the options cannot be used for this operation. Fix the request and create a new job. |
limit_exceeded, encrypted_pdf, processing_failed | The file is over a limit of your plan, is password protected, or could not be processed. The same file will fail again. |
input_fetch_failed | The input URL could not be fetched. The message says what went wrong. |
input_missing | The uploaded file was no longer there when the job started. Upload it again. |
output_upload_failed | The result could not be sent to your output.url. |
feature_not_enabled | The file is a video, or the output would be HEIC, and that feature is not enabled for the account. Both are in limited preview. |
timeout, internal | The job ran for more than 4 hours, waited more than 30 minutes for capacity, or hit a fault on our side. Creating the job again may work. |
What to retry, and how, is covered in Errors and retries. Every code is listed in Errors.
Cancel and delete
One call does both things, depending on where the job is:
- Queued or processing: the job is stopped. Its status becomes
canceled, with the error codecanceled, and it is not billed. - Any status: the input and the output are deleted from our storage immediately, without waiting for
expires_at. The download link stops working.
curl -X DELETE "https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW" \
-H "Authorization: Bearer $SMOL_API_KEY"{
"id": "job_8Hq2LmZx4TnV0cRb7KpW",
"object": "job",
"deleted": true
}The record of the job remains. Fetch it afterwards and it shows files_deleted: true. Deleting a succeeded job does not refund it. To purge a result the moment you have downloaded it, call DELETE right after the download.
Retention
| File | Deleted |
|---|---|
| Input (upload or fetched URL) | As soon as the job reaches a final status. |
| Output | At expires_at, which is retention_seconds after the job finished. |
retention_seconds is a whole number from 60 (one minute) to 86400 (24 hours). If you leave it out, the retention set for the account applies. That is one hour unless you have changed it in the dashboard.
{
"operation": "compress",
"input": { "upload": "upl_Zk3Vb9QeT1mXc7HsW0yN" },
"retention_seconds": 600
}Choose a value that covers the time between the job finishing and your download, with room for your own retries. The full picture of what is stored and when it is removed is in How files are handled. For a signed statement of the deletion times, see Receipts.
Metadata
metadata lets you attach your own references to a job, such as the id of the record the file belongs to. It is returned on the job object and in every webhook event for the job, so you do not need to keep your own table that maps job ids to records.
- Up to 20 entries.
- Keys of up to 40 characters, values of up to 500 characters.
- Values must be strings. Send numbers as strings.
Metadata is not used by the API in any way. Do not put secrets or personal data in it: it is stored with the job record for 30 days.
Idempotency
If POST /v1/jobs times out on the network, you cannot tell whether the job was created. Sending the request again could create, and bill, a second job. To make the request safe to repeat, send an Idempotency-Key header with a value that is unique to the job you intend, such as a UUID. It can be 1 to 255 characters long.
curl -X POST "https://api.smolmac.com/v1/jobs" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2f0e-5d0b-4f0a-9a57-0c9a3e1b7d42" \
-d '{ "operation": "compress", "input": { "upload": "upl_Zk3Vb9QeT1mXc7HsW0yN" } }'- A repeat with the same key and the same body does not create a job. It returns the response of the first request, with the header
Smol-Idempotent-Replay: true. - A repeat that arrives while the first request is still being handled is refused with
409 idempotency_in_progressandRetry-After: 1. Wait a second and send it again. Two requests sent at the same moment create one job, not two. - A repeat with the same key and a different body is refused with
409 idempotency_conflict. The body is compared byte for byte, so send exactly the same JSON. - A request that fails gives the key back. You can correct the request and send it again with the same key.
- A key is remembered for 24 hours.
The replayed response is the job as it was when it was created, with the status queued. Take the id from it and fetch the job to see its current state. More in Idempotency.