Documentation menu

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

POST/v1/uploads

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.

bash
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 }'
201 response
{
  "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.

PUT/v1/uploads/{id}/content

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.

bash
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
200 response
{
  "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.

201 response for a file of 250 MB
{
  "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"
}
  1. Send each part. PUT the bytes of part n to its url. Part n starts at byte (n − 1) × part_size of the file and is exactly size bytes long. A request with any other length is refused with 400 bad_request.
  2. Complete the upload. POST to complete_url with no body. The parts are joined into one file, and the upload is ready for a job.
bash
# 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 with 409 upload_already_completed.
  • A job cannot use the upload before it is completed. POST /v1/jobs answers 404 not_found until 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-Length header;
  • 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

POST/v1/jobs
bash
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" }
  }'
FieldTypeDescription
operation *stringcompress, convert or strip_metadata.
input *objectExactly 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.
optionsobjectThe options object. For convert, options.convert.to is required.
presetstringThe name or id of a preset. Its options are applied first, and the fields in options override them one by one.
outputobjectDeliver the result to your own storage instead of ours. See Output to your storage.
retention_secondsintegerHow long the result is kept after the job finishes, 60 to 86400. See Retention.
webhook_urlstringAn https URL to notify when this job finishes. See Webhooks.
metadataobjectYour 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:

202 response
{
  "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

GET/v1/jobs/{id}

Fetch the job to see where it is. Poll every few seconds until status is one of the three final values.

bash
curl "https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW" \
  -H "Authorization: Bearer $SMOL_API_KEY"
statusMeaningFinal?
queuedAccepted. Waiting for the input to be fetched or for capacity.No
processingThe file is being processed.No
succeededDone. result is filled in.Yes
failedNot done. error says why. Not billed.Yes
canceledYou 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.

bash
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 answers 403 invalid_signature.
  • The response has the content type of the output and a Content-Disposition header 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");

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");

The job object

This is the job from the example once it has succeeded:

GET /v1/jobs/{id}
{
  "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" }
}
FieldTypeDescription
idstringThe job id, starting job_.
livemodebooleantrue for a job created with a live key, false for a test key. A key only sees jobs of its own mode.
operationstringThe operation the job was created with.
statusstringOne of the five statuses above.
created_at, started_at, completed_atstring or nullISO 8601 times in UTC. The last two are null until the job reaches that point.
progress.percentinteger0, 50 or 100.
inputobjectfilename and size in bytes. For a URL input, size is null until the file has been fetched.
resultobject or nullNull 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.
errorobject or nullNull unless the job failed or was canceled. Then type, code and message.
billedbooleanWhether the job was charged.
expires_atstring or nullWhen the output is deleted. Null until the job is final.
files_deletedbooleanTrue when neither the input nor the output is stored by us any more.
metadataobjectThe 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.

a failed job (shortened)
{
  "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.codeCause
unsupported_input, unsupported_target, invalid_optionsThe file or the options cannot be used for this operation. Fix the request and create a new job.
limit_exceeded, encrypted_pdf, processing_failedThe file is over a limit of your plan, is password protected, or could not be processed. The same file will fail again.
input_fetch_failedThe input URL could not be fetched. The message says what went wrong.
input_missingThe uploaded file was no longer there when the job started. Upload it again.
output_upload_failedThe result could not be sent to your output.url.
feature_not_enabledThe 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, internalThe 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

DELETE/v1/jobs/{id}

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 code canceled, 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.
bash
curl -X DELETE "https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW" \
  -H "Authorization: Bearer $SMOL_API_KEY"
200 response
{
  "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

FileDeleted
Input (upload or fetched URL)As soon as the job reaches a final status.
OutputAt 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.

keep the result for ten minutes
{
  "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.

bash
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_progress and Retry-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.