Documentation menu

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

bash
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-api and is imported as smol_api.
  • If you call the API with Python's built-in urllib instead of this package, set a User-Agent header. The default one is refused with a 403 before the request reaches the API. This package and requests are 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

python
import os
from smol_api import Smol

smol = Smol(api_key=os.environ["SMOL_API_KEY"])
ArgumentTypeDescription
api_key *strA live or a test key. An empty value raises ValueError.
base_urlstrWhere the API is. Default https://api.smolmac.com. A trailing slash is removed.
max_retriesintHow many times a failed request is sent again. Default 2. See Errors and retries.
transportcallableA 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.

python
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")
MethodArgumentsNotes
compress(file, options=None, filename=None, preset=None)options: the options object, as a dictionaryImages, 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:

FieldTypeDescription
databytesThe bytes of the returned file.
content_typestrIts media type, for example image/avif.
filenamestr or NoneThe name from the Content-Disposition header: your file name with the extension of the output format.
resultdictThe result object.
billedboolWhether the request was charged.
request_idstr or NoneThe 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.

python
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 runTypeDescription
operation *strcompress, convert or strip_metadata.
file *bytesThe file.
filenamestrThe name of the file.
optionsdictThe options object of the job.
presetstrThe name or id of a preset.
timeoutfloatHow long to wait for the job, in seconds. Default 1800.
  • run returns a pair: the finished job object and the bytes of the result.
  • If the job ends as failed or canceled, run raises a SmolError whose code is the error code of the job, such as encrypted_pdf. If the job is still running at the timeout, it raises TimeoutError. The job keeps running on our side.
  • run has no parameters for output, webhook_url, metadata, retention_seconds or an idempotency key. Use the step-by-step calls for those.

Step by step

python
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"])
MethodDoes
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.

python
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:

MethodDoes
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.

python
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")
MethodDoes
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

python
# 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.

python
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.

python
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_webhookTypeDescription
raw_body *bytesThe request body, exactly as received.
signature_header *str or NoneThe value of the Smol-Signature header.
secret *strThe secret of the endpoint, or the account signing secret for a per-job webhook_url. See which secret signs what.
tolerance_secondsintHow far the timestamp of the signature may be from now. Default 300.
  • It raises ValueError if 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 ping of a test included. A ping has an empty data, so check event["type"] before you read event["data"]["job"].

Manage endpoints

python
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
MethodDoes
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.

python
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 SmolErrorTypeDescription
statusintThe HTTP status.
typestrThe family of the error, such as invalid_request_error.
codestrWhat went wrong, such as invalid_options. Branch on this.
paramstr or NoneThe field or option at fault, when there is one.
request_idstr or NoneThe id of the request.
retry_afterfloat or NoneThe Retry-After header in seconds, when the response had one.
retryableboolTrue 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_retries times, 2 by default, so a request is sent at most three times. Set max_retries=0 to turn retries off.
  • How long it waits. The seconds in Retry-After when 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, the OSError of the last attempt.
  • What is not retried. Every other status, including 409 idempotency_in_progress. Wait a second and call again yourself. The download in download is 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

WhatLimitHow to change it
One HTTP requestNone 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_job1,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
run1,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:

a client whose requests give up after 150 seconds
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_job gives up, the job goes on. Call wait_for_job or get_job again later, or use a webhook.