Create a key in the console, then
POST api.servicelabs.dev/v1/utilities/html-pdf — the answer is a job
with a link to your PDF.
Five utilities used to mean
five of everything.
Every useful little service — make a PDF, split one, zip a folder — shipped with its own URL, its own secret and its own quirks. Nobody could say who called what, how often, or how slowly.
Every caller hard-coded five endpoints and five signing keys — rotating one meant hunting down every service that used it.
A big PDF hit a 29-second gateway ceiling, and there was no job to come back to — just a timeout and a guess.
No way to answer “who uses split-pdf, and how slow is it?” — the data lived in five places, or nowhere.
ServiceLabs puts every utility behind one front door — one key, one job model, one place to see usage and latency.
Utilities you call, not servers you run
Each one takes a small JSON input, fetches its own source from a URL or s3://, and returns links to the files it produced.
HTML → PDF html-pdf
Render any HTML document to PDF in headless Chromium — page size, orientation, scale and margins, fonts and ₹ included.
Docs →Split PDF split-pdf
One single-page PDF per page, in order — for statements and bundles up to 150 MB and 500 pages.
Docs →Zip files zip-files
Hand it a list of URLs and paths; get back one .zip. Paths are validated, so no entry can escape the archive.
Docs →Unzip archive unzip-files
Extract every entry of a .zip and get one link per file, with its original path, size and content type.
Docs →XLS → XLSX xls-to-xlsx
Convert legacy .xls workbooks to .xlsx with every sheet preserved — values carried over cleanly.
Docs →More on the way
New utilities join the same key, job model and dashboards the day they ship. On the roadmap:
- OCR and invoice data extraction
- PDF → image
- Splitting large Excel files
A key, a call, a file
Same shape for every utility: send { "input": … }, get back a job.
Create a key
In the console → API keys. Scope it to the utilities a service needs and give it a rate limit. It's shown once; only a hash is stored.
Call it sync — or as a job
POST /v1/utilities/{slug} waits for the result.
POST /v1/utilities/{slug}/jobs answers 202 with a job
id straight away.
Get the result your way
The finished job carries result.files[].url. For async jobs, poll
GET /v1/jobs/{id}?wait=30 or receive a
signed callback.
# sync — waits for the finished PDF curl https://api.servicelabs.dev/v1/utilities/html-pdf \ -H "Authorization: Bearer $SERVICELABS_KEY" \ -H "Content-Type: application/json" \ -d '{"input": {"url": "https://acme.test/invoice.html", "pdfOptions": {"format": "A4"}}, "metadata": {"invoiceId": "INV-1042"}}'
// async — a job id now, the result when it's done const API = "https://api.servicelabs.dev/v1"; const auth = { Authorization: `Bearer ${process.env.SERVICELABS_KEY}` }; const job = await fetch(`${API}/utilities/split-pdf/jobs`, { method: "POST", headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": statementId }, body: JSON.stringify({ input: { url: "s3://acme-docs/statement.pdf" } }), }).then((r) => r.json()); // 202 { id: "job_…", status: "queued" } const done = await fetch(`${API}/jobs/${job.id}?wait=30`, { headers: auth }) .then((r) => r.json()); // long-polls until it's final done.result.files.forEach((f) => console.log(f.pageNumber, f.url));
# sync zip — one call, one archive import os, requests API = "https://api.servicelabs.dev/v1" H = {"Authorization": f"Bearer {os.environ['SERVICELABS_KEY']}"} job = requests.post(f"{API}/utilities/zip-files", headers=H, json={ "input": {"files": [ {"url": "https://acme.test/a.pdf", "fileName": "invoices/a.pdf"}, {"url": "https://acme.test/b.pdf", "fileName": "invoices/b.pdf"}, ]}, }).json() print(job["status"], job["result"]["files"][0]["url"])
HTTP/1.1 200 OK { "id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3", "utility": "html-pdf", "mode": "sync", "status": "succeeded", "timings": { "queueMs": 0, "upstreamMs": 2140, "storageMs": 196, "totalMs": 2359 }, "metadata": { "invoiceId": "INV-1042" }, "result": { "files": [{ "url": "https://files.servicelabs.dev/html-pdf/2026-09-10/job_01K7…/invoice.pdf", "name": "invoice.pdf", "size": 115741, "contentType": "application/pdf" }] }, "error": null, "links": { "self": "/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3" } }
Sync when it's quick.
A job id when it isn't.
A sync call waits up to 90 seconds. If the utility needs longer, you get 202 and a job id — and the work keeps going, up to 15 minutes. Idempotency keys make retries safe; transient failures are retried for you; the job's timeline shows every stage down to the millisecond.
# POST /v1/utilities/split-pdf (sync) ✓ accepted key checked, input validated ✓ running invoking split-pdf … 90 s pass still working → 202 + job id you're free to go ✓ utility answered 412 pages in 6m 12s ✓ files stored 412 links ✓ succeeded callback sent, signed
Everything around the utility, done once
Auth, jobs, callbacks, files and metrics are the platform's job — so every new utility gets them on day one.
One auth layer
Bearer API keys, scoped to utilities, with their own rate limit and optional expiry. Only a hash is stored; revoking takes effect on the next request. Every call is attributed to the key that made it.
Idempotent by design
Send Idempotency-Key and a retry returns the original job — never a
second run.
Signed callbacks
Standard Webhooks signatures with each key's own secret, retried 8× over ~21 minutes.
Usage & latency, per key
Requests, success rate and p50 / p95 / p99 — end to end and inside the utility — per utility and per API key, for the last hour or the last 30 days. Rollups outlive the raw jobs.
Files handled
Outputs come back as links with name, size and type, laid out per job.
Guarded egress
Callback URLs must be public HTTPS; private and loopback addresses are refused.
A console for the whole team
A playground to try any utility, a job timeline down to the millisecond, usage by key, members and invites. Google sign-in for staff, passwords and 2FA for everyone, and partner accounts that only ever see their own keys and jobs.
Docs that can't drift
The API reference is generated from the same schemas that validate every request —
plus llms.txt so your AI tools can read the docs too.
An API you can read,
generate and hand to an agent
One OpenAPI document describes every utility. Errors are stable codes, not strings to parse.
API reference
Every operation with request and response schemas, real examples, and a try-it panel.
OpenAPI 3.1
Generate a typed client in any language. Generated from the validators, so it always matches the API.
llms.txt for agents
The whole documentation as plain text — point Claude, Cursor or your own agent
at /docs/llms-full.txt.
Stable error codes
| code | HTTP | meaning |
|---|---|---|
| invalid_request | 400 | input failed validation — no job created |
| unauthorized | 401 | missing, revoked or expired key |
| rate_limited | 429 | per-key limit hit — honour Retry-After |
| upstream_rejected | 422 | the utility couldn't use your input |
| upstream_timeout | 504 | the utility ran past its limit |
// callbacks verify with the open Standard Webhooks lib import { Webhook } from "standardwebhooks"; // your key's whsec_… secret, shown when the key is created const wh = new Webhook(process.env.SERVICELABS_WHSEC); app.post("/hooks/servicelabs", rawJson, (req, res) => { const evt = wh.verify(req.body, req.headers); // throws if forged if (evt.type === "job.succeeded") { save(evt.data.result.files); // dedupe on webhook-id } res.sendStatus(200); });
# retry the same request safely — the second call replays the first job curl https://api.servicelabs.dev/v1/utilities/xls-to-xlsx/jobs \ -H "Authorization: Bearer $SERVICELABS_KEY" \ -H "Idempotency-Key: report-2026-09" \ -H "Content-Type: application/json" \ -d '{"input": {"url": "https://acme.test/report.xls"}}' HTTP/1.1 202 Accepted Location: /v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3 Idempotent-Replayed: true
# request errors: no job was created HTTP/1.1 400 Bad Request { "error": { "code": "invalid_request", "message": "input failed validation for html-pdf", "details": [{ "path": "input.url", "message": "must be an http://, https:// or s3:// URL" }] } } # failed runs: the job is kept, with the utility's own status HTTP/1.1 422 Unprocessable Entity { "id": "job_01K7…", "status": "failed", "error": { "code": "upstream_rejected", "upstreamStatus": 400, "message": "Failed to download source: HTTP 404" } }
Standard Webhooks, on the wire
Every callback is signed per the open standardwebhooks spec — webhook-id, webhook-timestamp, webhook-signature — so any off-the-shelf library verifies it. No proprietary SDK required.
Questions, answered
What is ServiceLabs?
Who can use it?
What does it cost?
How long can a utility run?
202 with a job id and keeps going. An async job can
run up to 15 minutes (5 for XLS → XLSX). Poll
GET /v1/jobs/{id}?wait=30 or pass a callbackUrl.
Where do the output files live, and for how long?
result.files[] with its name, size
and content type, stored under a per-job folder. Outputs are kept on a retention
schedule, so download what you need to keep. Job records are kept for 90
days; usage history is kept for good.
How is it secured?
Can we add our own utility?
One key. Every utility.
Sign in to the console, create a key, and make your first call in a couple of minutes.