API reference
Uploads
Send a file to the API so that a job can use it: in one request up to 95 MB, or in parts above that.
Overview
A job needs an input. An upload is one way to supply it: you create an upload, send the file's bytes to it, and pass its id to POST /v1/jobs as input.upload. The other way is to give the job a public input.url, which needs no upload.
| File size | How it is sent | Requests |
|---|---|---|
| Up to 95 MB (99,614,720 bytes) | In one request | POST /v1/uploads, then one PUT |
| Above 95 MB | In parts of 64 MB (67,108,864 bytes) | POST /v1/uploads, one PUT per part, then POST .../complete |
The response to POST /v1/uploads tells you which of the two applies, in multipart, and gives you every URL to send to. All of these requests are free, and all need your API key.
Create an upload
Send a JSON body. A field that is not listed here is refused with bad_request.
| Field | Type | Description |
|---|---|---|
| size * | integer | The size of the file in bytes. A whole number greater than 0, and no more than your plan's job file limit. |
| filename | string | The name of the file. Optional, but give it: it helps detect the file type and names the result. Only the last path component is kept. |
The response has status 201. For a file of up to 95 MB:
{
"id": "upl_Zk3q8WcT1nRb5LxYp0Ha",
"object": "upload",
"size": 48211930,
"method": "PUT",
"headers": {
"authorization": "Bearer <your API key>",
"content-type": "application/octet-stream"
},
"expires_at": "2026-10-02T12:00:00.000Z",
"multipart": false,
"url": "https://api.smolmac.com/v1/uploads/upl_Zk3q8WcT1nRb5LxYp0Ha/content?filename=report.pdf"
}For a larger file:
{
"id": "upl_M2xVq7TnLc0RbY5sKd9W",
"object": "upload",
"size": 262144000,
"method": "PUT",
"headers": {
"authorization": "Bearer <your API key>",
"content-type": "application/octet-stream"
},
"expires_at": "2026-10-02T12:00:00.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"
}| Field | Type | Description |
|---|---|---|
| id | string | The upload id. Pass it to a job as input.upload. |
| object | string | Always "upload". |
| size | integer | The size you declared. |
| method | string | Always "PUT". The method for url and for each part url. |
| headers | object | The headers to send with the bytes. The authorization value is a placeholder: replace it with your own key. |
| expires_at | string | When the upload is deleted if no job has used it: 24 hours after it was created. Send the content and create the job before then. |
| multipart | boolean | false: send the file in one request to url. true: send it in parts, then complete the upload. |
| url | string | Only when multipart is false. Where to send the bytes. It carries the file name as a query parameter when you gave one. |
| part_size | integer | Only when multipart is true. The size of every part but the last: 67108864. |
| parts | array | Only when multipart is true. One entry per part, in order: part_number (from 1), size (the exact number of bytes that part must carry) and url. |
| complete_url | string | Only when multipart is true. Where to send the request that completes the upload. |
Send the content in one request
Send the bytes of the file as the request body, to the url from the first response.
| Header | Type | Description |
|---|---|---|
| Authorization * | string | Bearer followed by your API key. Use a key of the same account as the first request. |
| Content-Length * | integer | The exact number of bytes in the body. A body that is shorter or longer makes the request fail. |
| Content-Type | string | application/octet-stream. The value is not used to detect the file type. |
| Query parameter | Type | Description |
|---|---|---|
| filename | string | The name of the file. Already present in the url you were given, if you named the file when you created the upload. |
The response has status 200. The upload is ready to be used by a job.
{
"id": "upl_Zk3q8WcT1nRb5LxYp0Ha",
"object": "upload",
"size": 48211930,
"uploaded": true
}Send the content in parts
For each entry in parts, send that slice of the file to the entry's url. Part n starts at byte (n − 1) × part_size of the file and is size bytes long. Send the same Authorization, Content-Length and Content-Type headers as for an upload in one request.
Content-Lengthmust equal the part'ssizeexactly. Any other length is refused with400 bad_request.- Parts may be sent in any order.
- Sending a part again replaces it. If a part fails, send that part again. The others are kept.
{
"id": "upl_M2xVq7TnLc0RbY5sKd9W",
"object": "upload_part",
"part_number": 1,
"size": 67108864
}Complete an upload in parts
When every part has been sent, send this request with no body. It joins the parts into one file. The upload is then ready to be used by a job.
{
"id": "upl_M2xVq7TnLc0RbY5sKd9W",
"object": "upload",
"size": 262144000,
"uploaded": true
}- If parts are missing, the request is refused with
409 upload_incomplete. The message lists the part numbers still to send. - An upload can be completed once. After that, sending a part or completing again is refused with
409 upload_already_completed. - A job cannot use an upload in parts until it has been completed. Until then
POST /v1/jobsreturns404 not_foundfor it.
Rules
- One upload, one job. A job deletes its input when it finishes, whatever the outcome. To process the same file again, upload it again.
- Use it within 24 hours. An upload that no job has used is deleted 24 hours after it was sent. An upload in parts that is not completed within 24 hours is discarded. The deletion is made by a clean-up that runs once an hour. See How files are handled.
- Size. The file can be as large as your plan's job file limit. See Limits.
- Scope. An upload belongs to the account, not to the key that made it. A job can use an upload made with any key of the same account.
- Test keys. A job made with a test key accepts an upload only if the file is one of the sample files. The check is made when the job is created. A test key cannot create an upload in parts, because no sample file is that large.
- Sending content twice to the same upload id replaces the first content.
Errors
| Code | Status | Request | When |
|---|---|---|---|
bad_request | 400 | Create | The body is not a JSON object, has an unknown field, or size is missing, not a whole number, or not above 0. |
bad_request | 400 | Send content, send a part | The body is empty, or the Content-Length header is missing or 0. For a part: the part number does not exist, or the length is not the size of that part. |
file_too_large | 413 | Create, send content | The size is over the job file limit of your plan. For content sent in one request: over 95 MB. The message says which of the two limits was hit, and for the 95 MB limit it tells you to create the upload with the real size of the file, so that you are given parts. |
test_mode_sample_required | 402 | Create | A test key asked for an upload above 95 MB. |
not_found | 404 | Send content, send a part, complete | Send content: the id in the path is not an upload id. Send a part, complete: the id is not an upload in parts of this account. |
upload_incomplete | 409 | Complete | Some parts have not been sent. |
upload_already_completed | 409 | Send a part, complete | The upload has already been completed. |
missing_key, invalid_key, revoked_key | 401 | All | The API key is missing, wrong or revoked. |
no_payment_method, payment_required | 402 | All | A live key on an account with no plan, or with an unpaid invoice. |
account_suspended, ip_not_allowed | 403 | All | The account is suspended, or the key may not be used from this IP address. |
Full example
One request
# 1. Create the upload. The response contains "id" and "url".
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":48211930}'
# 2. Send the bytes to the url from step 1. curl sets Content-Length itself.
curl -X PUT "https://api.smolmac.com/v1/uploads/upl_Zk3q8WcT1nRb5LxYp0Ha/content?filename=report.pdf" \
-H "Authorization: Bearer $SMOL_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @report.pdf
# 3. Start a job with the upload id.
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_Zk3q8WcT1nRb5LxYp0Ha"}}'Any size
This code handles both cases. It reads multipart from the response and sends the file in one request or in parts. The SDKs have the same logic built in: smol.uploads.upload() in TypeScript and smol.upload() in Python.
import { open, stat } from "node:fs/promises";
const api = "https://api.smolmac.com";
const auth = { Authorization: `Bearer ${process.env.SMOL_API_KEY}` };
const path = "talk.mov";
async function call(url, init) {
const response = await fetch(url, init);
const body = await response.json();
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body;
}
// fetch sets Content-Length from the buffer.
const put = (url, bytes) =>
call(url, { method: "PUT", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: bytes });
// 1. Create the upload.
const { size } = await stat(path);
const upload = await call(`${api}/v1/uploads`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ filename: path, size }),
});
// 2. Send the bytes: in one request, or part by part.
const file = await open(path);
try {
if (!upload.multipart) {
await put(upload.url, await file.readFile());
} else {
for (const part of upload.parts) {
const bytes = Buffer.alloc(part.size);
await file.read(bytes, 0, part.size, (part.part_number - 1) * upload.part_size);
await put(part.url, bytes);
}
await call(upload.complete_url, { method: "POST", headers: auth });
}
} finally {
await file.close();
}
// 3. Start a job with the upload id.
const job = await call(`${api}/v1/jobs`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ operation: "compress", input: { upload: upload.id } }),
});
console.log(job.id, job.status);import os
import requests
api = "https://api.smolmac.com"
auth = {"Authorization": f"Bearer {os.environ['SMOL_API_KEY']}"}
binary = {"Content-Type": "application/octet-stream"}
path = "talk.mov"
def call(method, url, **kwargs):
response = requests.request(method, url, headers={**auth, **kwargs.pop("headers", {})}, **kwargs)
body = response.json()
if not response.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
return body
# 1. Create the upload.
upload = call("POST", f"{api}/v1/uploads", json={"filename": path, "size": os.path.getsize(path)})
# 2. Send the bytes: in one request, or part by part. requests sets Content-Length from the bytes.
with open(path, "rb") as f:
if not upload["multipart"]:
call("PUT", upload["url"], data=f.read(), headers=binary)
else:
for part in upload["parts"]: # in order, so each read continues where the last one ended
call("PUT", part["url"], data=f.read(part["size"]), headers=binary)
call("POST", upload["complete_url"])
# 3. Start a job with the upload id.
job = call("POST", f"{api}/v1/jobs", json={"operation": "compress", "input": {"upload": upload["id"]}})
print(job["id"], job["status"])