SDKs
Python SDK
The smol-api package: a client for Python 3.9 and later with no dependencies, with retries, uploads in parts and webhook verification.
Install
pip install https://smolmac.com/sdk/smol_api-0.1.0-py3-none-any.whl- The package is served from this site. It is called
smol-apiand is imported assmol_api. - If you call the API with Python's built-in
urllibinstead of this package, set aUser-Agentheader. The default one is refused with a 403 before the request reaches the API. This package andrequestsare not affected. - It needs Python 3.9 or later. It uses only the standard library, so it installs nothing else.
- The client is synchronous. Each call returns when the API has answered.
- API keys are secrets. Use the package on a server, not in code you ship to users.
Create a client
import os
from smol_api import Smol
smol = Smol(api_key=os.environ["SMOL_API_KEY"])| Argument | Type | Description |
|---|---|---|
| api_key * | str | A live or a test key. An empty value raises ValueError. |
| base_url | str | Where the API is. Default https://api.smolmac.com. A trailing slash is removed. |
| max_retries | int | How many times a failed request is sent again. Default 2. See Errors and retries. |
| transport | callable | A function that sends one HTTP request, to use in place of the built-in one. Use it to add a timeout or a proxy. See Timeouts. |
The package exports Smol, SmolError, FileResult and verify_webhook. Objects of the API, such as jobs, are returned as plain dictionaries with the field names the API uses.
Direct requests
For files of up to 25 MB. The file goes in and the result comes back in the same call. See Compress, Convert and Strip metadata.
with open("photo.jpg", "rb") as f:
photo = f.read()
# Compress. Options are the same object as everywhere else in the API.
out = smol.compress(
photo,
{"image": {"format": "avif", "quality": 60, "resize": {"width": 1200}}},
filename="photo.jpg",
)
with open(out.filename or "photo.avif", "wb") as f:
f.write(out.data)
print(out.result["savings_percent"], out.billed)
# Convert to another format.
webp = smol.convert(photo, "webp", filename="photo.jpg")
# Remove metadata without re-encoding.
clean = smol.strip_metadata(photo, filename="photo.jpg")| Method | Arguments | Notes |
|---|---|---|
compress(file, options=None, filename=None, preset=None) | options: the options object, as a dictionary | Images, PDFs and audio. |
convert(file, to, options=None, filename=None, preset=None) | to: the target format, such as "webp" | Sets convert.to for you. Add page and dpi under convert in options for a PDF page. |
strip_metadata(file, filename=None) | The operation has no options. | Images and PDFs. |
file is the content of the file as bytes. Send filename when you have it: text formats such as CSV are recognised by their name, and the name is used for the result. Each method returns a FileResult:
| Field | Type | Description |
|---|---|---|
| data | bytes | The bytes of the returned file. |
| content_type | str | Its media type, for example image/avif. |
| filename | str or None | The name from the Content-Disposition header: your file name with the extension of the output format. |
| result | dict | The result object. |
| billed | bool | Whether the request was charged. |
| request_id | str or None | The id of the request. Quote it to support. |
A file that cannot be made smaller comes back unchanged, with result["kept_original"] true and billed false. See Kept original.
Jobs
For files over 25 MB, and for all video and office documents. run does the whole flow: it uploads the file, creates the job, waits for it and downloads the result.
with open("report.docx", "rb") as f:
job, data = smol.run("convert", f.read(), filename="report.docx", options={"convert": {"to": "pdf"}})
with open("report.pdf", "wb") as f:
f.write(data)
print(job["id"], job["result"]["output_size"])| Argument of run | Type | Description |
|---|---|---|
| operation * | str | compress, convert or strip_metadata. |
| file * | bytes | The file. |
| filename | str | The name of the file. |
| options | dict | The options object of the job. |
| preset | str | The name or id of a preset. |
| timeout | float | How long to wait for the job, in seconds. Default 1800. |
runreturns a pair: the finished job object and the bytes of the result.- If the job ends as failed or canceled,
runraises aSmolErrorwhosecodeis the error code of the job, such asencrypted_pdf. If the job is still running at the timeout, it raisesTimeoutError. The job keeps running on our side. runhas no parameters foroutput,webhook_url,metadata,retention_secondsor an idempotency key. Use the step-by-step calls for those.
Step by step
import uuid
# 1. Upload. Returns the upload id.
with open("scans.pdf", "rb") as f:
upload_id = smol.upload(f.read(), "scans.pdf")
# 2. Create the job.
created = smol.create_job(
{
"operation": "compress",
"input": {"upload": upload_id},
"options": {"pdf": {"quality": "small"}},
"retention_seconds": 600,
"metadata": {"document_id": "doc_1234"},
},
idempotency_key=str(uuid.uuid4()),
)
# 3. Wait for a final status.
job = smol.wait_for_job(created["id"], timeout=600)
if job["status"] != "succeeded":
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
# 4. Download, then delete the files on our side without waiting for them to expire.
data = smol.download(job)
smol.delete_job(job["id"])| Method | Does |
|---|---|
create_job(params, idempotency_key=None) | POST /v1/jobs. params is the request body as a dictionary: operation, input, options, preset, output, retention_seconds, webhook_url, metadata. idempotency_key is sent as the Idempotency-Key header. |
get_job(job_id) | Returns the job object. |
list_jobs(limit=None, starting_after=None, status=None) | Lists jobs, newest first. |
wait_for_job(job_id, timeout=1800, interval=1.0) | Polls until the job is succeeded, failed or canceled, and returns it. It does not raise for a failed job. It raises TimeoutError if the job is still running after timeout seconds. |
download(job) | Fetches job["result"]["download_url"] and returns the bytes. The link is signed, so the API key is not sent with it. Raises ValueError if the job has no download link. |
get_receipt(job_id) | Returns the signed receipt of a finished job. |
delete_job(job_id) | Cancels the job if it is still running, and deletes its files now. |
An input can also be a URL: "input": {"url": "https://..."}. Nothing is uploaded then, and the job fetches the file itself.
Large files
upload(file, filename=None) sends a file of any size your plan allows and returns the upload id. Up to 95 MB it is one request. Above that the client splits the file into the parts the API asks for, sends them one after another, and completes the upload. run uses it, so run handles large files too.
with open("talk.mov", "rb") as f:
upload_id = smol.upload(f.read(), "talk.mov")
job = smol.create_job({
"operation": "compress",
"input": {"upload": upload_id},
"options": {"video": {"codec": "h264", "resolution": "1920"}},
"webhook_url": "https://example.com/smol/webhook",
})- The file is passed as bytes, so the whole file is in memory while it is sent. For a file too large for that, put it at an https URL and create the job with
input.url. - Each part is retried on its own, like any other request.
The four calls that upload is built from are public too:
| Method | Does |
|---|---|
create_upload(size, filename=None) | POST /v1/uploads. Returns the upload, with multipart and, when it is true, parts. |
put_upload(upload_id, file, filename=None) | Sends the content of an upload of up to 95 MB in one request. |
put_upload_part(upload_id, part_number, data) | Sends one part. The data must be exactly the size the plan gives for that part. |
complete_upload(upload_id) | Joins the parts once all have been sent. |
Presets
A preset is a set of options saved on the account under a name.
smol.put_preset("web-hero", {"image": {"format": "avif", "quality": 60, "resize": {"width": 1600}}})
# On a direct request. Options sent with the request override the preset field by field.
hero = smol.compress(photo, preset="web-hero", filename="photo.jpg")
sharper = smol.compress(photo, {"image": {"quality": 80}}, preset="web-hero")
# On a job.
job, data = smol.run("compress", photo, filename="photo.jpg", preset="web-hero")| Method | Does |
|---|---|
put_preset(name, options) | Creates the preset, or replaces the one with that name. |
list_presets() | Returns every preset of the account. |
get_preset(id_or_name) | Returns one preset. |
delete_preset(id_or_name) | Deletes one preset. |
Usage
# The current month so far.
month = smol.usage()
print(month["totals"]["requests"], month["totals"]["amount_usd"])
# A range of days, in UTC. Both ends are included.
week = smol.usage(from_day="2026-10-01", to_day="2026-10-07")
for day in week["daily"]:
print(day["day"], day["meters"])A live key returns live usage and a test key test usage. The fields are described in Usage.
Estimate
estimate(operation, filename, size, options=None, **details) tells you what a request would cost and produce. No file is sent. details takes the other fields of POST /v1/estimate: duration_seconds, pages, width and height.
estimate = smol.estimate(
"compress",
"talk.mov",
262_144_000,
options={"video": {"codec": "h264"}},
duration_seconds=150, # needed for audio and video
width=1920,
height=1080,
)
print(estimate["lane"], estimate["meter"], estimate["quantity"], estimate["amount_usd"], estimate["notes"])Webhooks
Verify a delivery
verify_webhook is a function of the module, not a method of the client. It checks the Smol-Signature header of a delivery and returns the parsed event. Pass the request body as bytes, exactly as it was received, before any JSON parsing.
import os
from flask import Flask, abort, request
from smol_api import verify_webhook
app = Flask(__name__)
@app.post("/smol/webhook")
def smol_webhook():
try:
event = verify_webhook(
request.get_data(),
request.headers.get("Smol-Signature"),
os.environ["SMOL_WEBHOOK_SECRET"],
)
except ValueError:
abort(400)
if event["type"] == "job.succeeded":
# Queue the work, then answer.
print(event["data"]["job"]["id"], event["data"]["job"]["result"]["download_url"])
return "", 204| Argument of verify_webhook | Type | Description |
|---|---|---|
| raw_body * | bytes | The request body, exactly as received. |
| signature_header * | str or None | The value of the Smol-Signature header. |
| secret * | str | The secret of the endpoint, or the account signing secret for a per-job webhook_url. See which secret signs what. |
| tolerance_seconds | int | How far the timestamp of the signature may be from now. Default 300. |
- It raises
ValueErrorif the header is missing or malformed, if the timestamp is outside the tolerance, or if the signature does not match. - It returns every event type, the
pingof a test included. A ping has an emptydata, so checkevent["type"]before you readevent["data"]["job"].
Manage endpoints
endpoint = smol.create_webhook_endpoint("https://example.com/smol/webhook")
print(endpoint["id"], endpoint["secret"]) # the secret is shown only here
# Send a signed ping and see what your endpoint answered.
test = smol.test_webhook_endpoint(endpoint["id"])
print(test["delivered"], test["status"], test["duration_ms"])
smol.update_webhook_endpoint(endpoint["id"], enabled=False) # pause
smol.update_webhook_endpoint(endpoint["id"], enabled=True) # resume
rotated = smol.rotate_webhook_secret(endpoint["id"])
print(rotated["secret"]) # the new secret, shown only here| Method | Does |
|---|---|
create_webhook_endpoint(url) | Registers an endpoint. The answer carries its secret, once. |
list_webhook_endpoints() | Lists the endpoints of the account. Secrets are not included. |
update_webhook_endpoint(endpoint_id, enabled) | Pauses or resumes deliveries to an endpoint. |
delete_webhook_endpoint(endpoint_id) | Deletes an endpoint. |
rotate_webhook_secret(endpoint_id) | Replaces the secret and returns the endpoint with the new one, once. Accept both secrets for a day: see Rotate the secret. |
test_webhook_endpoint(endpoint_id) | Sends one signed ping event and returns event_id, delivered, status and duration_ms. |
webhook_signing_secret() | Returns the account signing secret, as a string. It signs deliveries to a job's webhook_url. |
Errors and retries
A response with an error status is raised as a SmolError. Its attributes come from the error object of the API.
from smol_api import SmolError
try:
smol.compress(photo, {"image": {"quality": 120}})
except SmolError as error:
print(error.status, error.code, error.param, error, error.request_id)| Attribute of SmolError | Type | Description |
|---|---|---|
| status | int | The HTTP status. |
| type | str | The family of the error, such as invalid_request_error. |
| code | str | What went wrong, such as invalid_options. Branch on this. |
| param | str or None | The field or option at fault, when there is one. |
| request_id | str or None | The id of the request. |
| retry_after | float or None | The Retry-After header in seconds, when the response had one. |
| retryable | bool | True for status 429, 500, 503 and 504. |
str(error) is the message of the error. The client retries by itself:
- What. A response with status 429, 500, 503 or 504, and a request that failed on the network before any answer came back (an
OSError). - How often.
max_retriestimes, 2 by default, so a request is sent at most three times. Setmax_retries=0to turn retries off. - How long it waits. The seconds in
Retry-Afterwhen the response has the header. Otherwise a random time between half and the whole of a base that starts at half a second and doubles with each attempt, up to 8 seconds. - What is raised in the end. The last
SmolError. If every attempt failed on the network, theOSErrorof the last attempt. - What is not retried. Every other status, including
409 idempotency_in_progress. Wait a second and call again yourself. The download indownloadis not retried either.
A retry of a direct request that did succeed, but whose answer was lost, is a second request and is billed as one. See Errors and retries.
Timeouts
| What | Limit | How to change it |
|---|---|---|
| One HTTP request | None is set by the client. The request waits as long as the socket and the server allow. | Pass your own transport, as below. |
wait_for_job | 1,800 seconds. It polls at once, again after 1 second, and then waits 1.5 times longer each time, up to 10 seconds. | timeout and interval, in seconds |
run | 1,800 seconds of waiting for the job. The upload and the download are not counted. | timeout |
A transport is a function that takes the method, the URL, the headers and the body, and returns the status, the response headers with lower-case names, and the response body. This one is the built-in transport with a timeout added:
import os
import urllib.error
import urllib.request
from smol_api import Smol
def transport(method, url, headers, body):
request = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=150) as response:
return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read()
except urllib.error.HTTPError as error:
return error.code, {k.lower(): v for k, v in error.headers.items()}, error.read()
smol = Smol(api_key=os.environ["SMOL_API_KEY"], transport=transport)- On our side a direct request is processed for at most 120 seconds, and then answers
504 timeout. Give your own timeout some room above that. - A request cut off by your timeout raises an
OSError, so it is retried like any network failure. A large upload needs a longer timeout than a small request. - A job may run for up to 4 hours. When
wait_for_jobgives up, the job goes on. Callwait_for_joborget_jobagain later, or use a webhook.