Documentation menu

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

bash
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, or await 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

typescript
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 });
OptionTypeDescription
apiKey *stringA live or a test key. The constructor throws if it is empty.
baseUrlstringWhere the API is. Default https://api.smolmac.com. A trailing slash is removed.
maxRetriesnumberHow many times a failed request is sent again. Default 2. See Errors and retries.
fetchfunctionA 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.

typescript
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" });
MethodArgumentsNotes
compress(file, options?)options: the options object, plus filename and presetImages, PDFs and audio.
convert(file, to, options?)to: the target format, such as "webp". The same options as compressSets convert.to for you. Add convert.page and convert.dpi in options for a PDF page.
stripMetadata(file, options?)options.filenameImages 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:

FieldTypeDescription
dataUint8ArrayThe bytes of the returned file.
contentTypestringIts media type, for example image/avif.
filenamestring or nullThe name from the Content-Disposition header: your file name with the extension of the output format.
resultResultThe result object, with its fields in snake_case as the API sends them.
billedbooleanWhether the request was charged.
requestIdstring or nullThe 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.

typescript
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 runTypeDescription
operation *stringcompress, convert or strip_metadata.
file *Uint8Array, ArrayBuffer or BlobThe file.
options.filenamestringThe name of the file.
options.optionsOptionsThe options object of the job.
options.presetstringThe name or id of a preset.
options.timeoutMsnumberHow long to wait for the job, in milliseconds. Default 30 minutes.
  • run returns { job, data }: the finished job object and the bytes of the result.
  • If the job ends as failed or canceled, run throws 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 throws a plain Error. 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

typescript
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);
MethodDoes
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.

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

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

typescript
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" });
MethodDoes
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

typescript
// 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.

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

typescript
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.verifyTypeDescription
rawBody *stringThe request body, exactly as received.
signatureHeader *string or nullThe value of the Smol-Signature header.
secret *stringThe secret of the endpoint, or the account signing secret for a per-job webhook_url. See which secret signs what.
toleranceSecondsnumberHow far the timestamp of the signature may be from now. Default 300.
  • It throws an Error 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.
  • 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-webcrypto flag.

Manage endpoints

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

typescript
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 SmolErrorTypeDescription
statusnumberThe HTTP status.
typestringThe family of the error, such as invalid_request_error.
codestringWhat went wrong, such as invalid_options. Branch on this.
messagestringAn explanation for a person.
paramstring or nullThe field or option at fault, when there is one.
requestIdstring or nullThe id of the request.
retryAfternumber or nullThe Retry-After header in seconds, when the response had one.
retryablebooleantrue 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. maxRetries times, 2 by default, so a request is sent at most three times. Set maxRetries: 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 thrown in the end. The last SmolError. If every attempt failed on the network, the error fetch raised.
  • What is not retried. Every other status, including 409 idempotency_in_progress. Wait a second and call again yourself. The download in jobs.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 runtime and the server allow.Pass your own fetch, as below.
jobs.wait30 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
run30 minutes of waiting for the job. The upload and the download are not counted.timeoutMs
a client whose requests give up after 150 seconds
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.wait gives up, the job goes on. Call jobs.wait or jobs.get again later, or use a webhook.