SDKs
TypeScript SDK
The @smolmac/api package: a typed client for Node.js and other JavaScript runtimes, with retries, uploads in parts and webhook verification.
Install
npm install https://smolmac.com/sdk/smolmac-api-0.1.0.tgz- The package is served from this site. It installs under the name
@smolmac/api, which is what you import. - The package has no dependencies and ships its own type definitions.
- It needs a global
fetch: Node.js 18 or later, Deno, Bun or Cloudflare Workers. - It is an ES module. Use
import, orawait import()from CommonJS code. - API keys are secrets. Use the package on a server, never in a browser. The API sends no CORS headers, so a browser could not call it anyway.
Create a client
import { Smol } from "@smolmac/api";
const apiKey = process.env.SMOL_API_KEY;
if (!apiKey) throw new Error("SMOL_API_KEY is not set");
const smol = new Smol({ apiKey });| Option | Type | Description |
|---|---|---|
| apiKey * | string | A live or a test key. The constructor throws if it is empty. |
| baseUrl | string | Where the API is. Default https://api.smolmac.com. A trailing slash is removed. |
| maxRetries | number | How many times a failed request is sent again. Default 2. See Errors and retries. |
| fetch | function | A fetch to use in place of the global one. Use it to add a timeout, a proxy or logging. See Timeouts. |
Smol is also the default export. The package exports the types used below, such as Options, Result, Job and FileResult, and the error class SmolError.
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.
import { readFile, writeFile } from "node:fs/promises";
const photo = await readFile("photo.jpg");
// Compress. Options are the same object as everywhere else in the API.
const out = await smol.compress(photo, {
filename: "photo.jpg",
image: { format: "avif", quality: 60, resize: { width: 1200 } },
});
await writeFile(out.filename ?? "photo.avif", out.data);
console.log(out.result.savings_percent, out.billed);
// Convert to another format.
const webp = await smol.convert(photo, "webp", { filename: "photo.jpg" });
// Remove metadata without re-encoding.
const clean = await smol.stripMetadata(photo, { filename: "photo.jpg" });| Method | Arguments | Notes |
|---|---|---|
compress(file, options?) | options: the options object, plus filename and preset | Images, PDFs and audio. |
convert(file, to, options?) | to: the target format, such as "webp". The same options as compress | Sets convert.to for you. Add convert.page and convert.dpi in options for a PDF page. |
stripMetadata(file, options?) | options.filename | Images and PDFs. |
file is a Uint8Array (a Node.js Buffer is one), an ArrayBuffer or a Blob. 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 | Uint8Array | The bytes of the returned file. |
| contentType | string | Its media type, for example image/avif. |
| filename | string or null | The name from the Content-Disposition header: your file name with the extension of the output format. |
| result | Result | The result object, with its fields in snake_case as the API sends them. |
| billed | boolean | Whether the request was charged. |
| requestId | string or null | 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.
const { job, data } = await smol.run("convert", await readFile("report.docx"), {
filename: "report.docx",
options: { convert: { to: "pdf" } },
});
await writeFile("report.pdf", data);
console.log(job.id, job.result?.output_size);| Argument of run | Type | Description |
|---|---|---|
| operation * | string | compress, convert or strip_metadata. |
| file * | Uint8Array, ArrayBuffer or Blob | The file. |
| options.filename | string | The name of the file. |
| options.options | Options | The options object of the job. |
| options.preset | string | The name or id of a preset. |
| options.timeoutMs | number | How long to wait for the job, in milliseconds. Default 30 minutes. |
runreturns{ job, data }: the finished job object and the bytes of the result.- If the job ends as failed or canceled,
runthrows aSmolErrorwhosecodeis the error code of the job, such asencrypted_pdf. If the job is still running at the timeout, it throws a plainError. 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 { randomUUID } from "node:crypto";
// 1. Upload. Returns the upload id.
const uploadId = await smol.uploads.upload(await readFile("scans.pdf"), "scans.pdf");
// 2. Create the job. The second argument is optional.
const created = await smol.jobs.create(
{
operation: "compress",
input: { upload: uploadId },
options: { pdf: { quality: "small" } },
retention_seconds: 600,
metadata: { document_id: "doc_1234" },
},
{ idempotencyKey: randomUUID() },
);
// 3. Wait for a final status.
const job = await smol.jobs.wait(created.id, { timeoutMs: 10 * 60_000 });
if (job.status !== "succeeded") throw new Error(`${job.error?.code}: ${job.error?.message}`);
// 4. Download, then delete the files on our side without waiting for them to expire.
const data = await smol.jobs.download(job);
await smol.jobs.delete(job.id);| Method | Does |
|---|---|
jobs.create(params, options?) | POST /v1/jobs. params has the fields of the request body: operation, input, options, preset, output, retention_seconds, webhook_url, metadata. options.idempotencyKey is sent as the Idempotency-Key header. |
jobs.get(id) | Returns the job object. |
jobs.list(params?) | Lists jobs, newest first. params: limit, starting_after, status. |
jobs.wait(id, options?) | Polls until the job is succeeded, failed or canceled, and returns it. It does not throw for a failed job. options: timeoutMs (default 30 minutes) and intervalMs (default 1,000). |
jobs.download(job) | Fetches job.result.download_url and returns the bytes. The link is signed, so the API key is not sent with it. Throws if the job has no download link. |
jobs.receipt(id) | Returns the signed receipt of a finished job. |
jobs.delete(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
uploads.upload(file, filename?) 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.
const uploadId = await smol.uploads.upload(await readFile("talk.mov"), "talk.mov");
const job = await smol.jobs.create({
operation: "compress",
input: { upload: uploadId },
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 |
|---|---|
uploads.create({ filename?, size }) | POST /v1/uploads. Returns the upload, with multipart and, when it is true, parts. |
uploads.put(id, file, filename?) | Sends the content of an upload of up to 95 MB in one request. |
uploads.putPart(id, partNumber, bytes) | Sends one part. The bytes must be exactly the size the plan gives for that part. |
uploads.complete(id) | Joins the parts once all have been sent. |
Presets
A preset is a set of options saved on the account under a name.
await smol.presets.put("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.
const hero = await smol.compress(photo, { preset: "web-hero", filename: "photo.jpg" });
const sharper = await smol.compress(photo, { preset: "web-hero", image: { quality: 80 } });
// On a job.
const { data } = await smol.run("compress", photo, { preset: "web-hero", filename: "photo.jpg" });| Method | Does |
|---|---|
presets.put(name, options) | Creates the preset, or replaces the one with that name. |
presets.list() | Returns every preset of the account. |
presets.get(idOrName) | Returns one preset. |
presets.delete(idOrName) | Deletes one preset. |
Usage
// The current month so far.
const month = await smol.usage();
console.log(month.totals.requests, month.totals.amount_usd);
// A range of days, in UTC. Both ends are included.
const range = await smol.usage({ from: "2026-10-01", to: "2026-10-07" });
for (const day of range.daily) console.log(day.day, day.meters);A live key returns live usage and a test key test usage. The fields are described in Usage.
Estimate
estimate tells you what a request would cost and produce. No file is sent. It takes the fields of POST /v1/estimate.
const estimate = await smol.estimate({
operation: "compress",
filename: "talk.mov",
size: 262_144_000,
duration_seconds: 150, // needed for audio and video
width: 1920,
height: 1080,
options: { video: { codec: "h264" } },
});
console.log(estimate.lane, estimate.meter, estimate.quantity, estimate.amount_usd, estimate.notes);Webhooks
Verify a delivery
webhooks.verify checks the Smol-Signature header of a delivery and returns the parsed event. Pass the request body as a string, exactly as it was received, before any JSON parsing.
import express from "express";
const app = express();
app.post("/smol/webhook", express.text({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await smol.webhooks.verify(req.body, req.get("Smol-Signature") ?? null, process.env.SMOL_WEBHOOK_SECRET ?? "");
} catch {
return res.status(400).send("invalid signature");
}
if (event.type === "job.succeeded") {
// Queue the work, then answer.
console.log(event.data.job.id, event.data.job.result?.download_url);
}
res.status(204).end();
});| Argument of webhooks.verify | Type | Description |
|---|---|---|
| rawBody * | string | The request body, exactly as received. |
| signatureHeader * | string or null | The value of the Smol-Signature header. |
| secret * | string | The secret of the endpoint, or the account signing secret for a per-job webhook_url. See which secret signs what. |
| toleranceSeconds | number | How far the timestamp of the signature may be from now. Default 300. |
- It throws an
Errorif 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.typebefore you readevent.data.job. - It uses the Web Crypto API (
crypto.subtle) as a global. Node.js 20 and later have it. Node.js 18 needs the--experimental-global-webcryptoflag.
Manage endpoints
const endpoint = await smol.webhookEndpoints.create("https://example.com/smol/webhook");
console.log(endpoint.id, endpoint.secret); // the secret is shown only here
// Send a signed ping and see what your endpoint answered.
const test = await smol.webhookEndpoints.test(endpoint.id);
console.log(test.delivered, test.status, test.duration_ms);
await smol.webhookEndpoints.update(endpoint.id, { enabled: false }); // pause
await smol.webhookEndpoints.update(endpoint.id, { enabled: true }); // resume
const rotated = await smol.webhookEndpoints.rotateSecret(endpoint.id);
console.log(rotated.secret); // the new secret, shown only here| Method | Does |
|---|---|
webhookEndpoints.create(url) | Registers an endpoint. The answer carries its secret, once. |
webhookEndpoints.list() | Lists the endpoints of the account. Secrets are not included. |
webhookEndpoints.update(id, { enabled }) | Pauses or resumes deliveries to an endpoint. |
webhookEndpoints.delete(id) | Deletes an endpoint. |
webhookEndpoints.rotateSecret(id) | Replaces the secret and returns the new one, once. Accept both secrets for a day: see Rotate the secret. |
webhookEndpoints.test(id) | Sends one signed ping event and returns event_id, delivered, status and duration_ms. |
webhookEndpoints.signingSecret() | 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 thrown as a SmolError. Its fields come from the error object of the API.
import { SmolError } from "@smolmac/api";
try {
await smol.compress(photo, { image: { quality: 120 } });
} catch (error) {
if (error instanceof SmolError) {
console.error(error.status, error.code, error.param, error.message, error.requestId);
} else {
throw error; // a network failure, after the retries
}
}| Field of SmolError | Type | Description |
|---|---|---|
| status | number | The HTTP status. |
| type | string | The family of the error, such as invalid_request_error. |
| code | string | What went wrong, such as invalid_options. Branch on this. |
| message | string | An explanation for a person. |
| param | string or null | The field or option at fault, when there is one. |
| requestId | string or null | The id of the request. |
| retryAfter | number or null | The Retry-After header in seconds, when the response had one. |
| retryable | boolean | true for status 429, 500, 503 and 504. |
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.
- How often.
maxRetriestimes, 2 by default, so a request is sent at most three times. SetmaxRetries: 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 thrown in the end. The last
SmolError. If every attempt failed on the network, the errorfetchraised. - What is not retried. Every other status, including
409 idempotency_in_progress. Wait a second and call again yourself. The download injobs.downloadis 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 runtime and the server allow. | Pass your own fetch, as below. |
jobs.wait | 30 minutes. It polls at once, again after 1 second, and then waits 1.5 times longer each time, up to 10 seconds. | timeoutMs and intervalMs |
run | 30 minutes of waiting for the job. The upload and the download are not counted. | timeoutMs |
const smol = new Smol({
apiKey,
fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(150_000) }),
});- 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 counts as a network failure, so it is retried like one. A large upload needs a longer timeout than a small request.
- A job may run for up to 4 hours. When
jobs.waitgives up, the job goes on. Calljobs.waitorjobs.getagain later, or use a webhook.