Appearance
API reference
The ServiceLabs API is one authenticated REST/JSON surface in front of every utility. One base URL, one kind of key, one resource — the job — whether you wait for the result or pick it up later.
This page is the on-ramp: authenticate, make a call, read the response, handle errors. The cross-cutting rules (IDs, error codes, headers) are in Conventions & errors; every endpoint with its schemas is in API operations.
Base URL & versioning
https://api.servicelabs.devEvery path carries a /v1 prefix. Changes within v1 are additive — new fields and optional parameters can appear at any time, so ignore fields you don't know. A breaking change would ship under /v2.
Requests and responses are JSON. Send Content-Type: application/json on every request with a body.
Authentication
Every request carries an API key as a bearer token:
bash
curl https://api.servicelabs.dev/v1/utilities \
-H "Authorization: Bearer du_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Create keys in the console under API keys (how keys work). A key can be limited to specific utilities and carries its own rate limit. Only a hash is stored — the full key is shown once.
| Situation | Response |
|---|---|
No Authorization header, or not Bearer <key> | 401 unauthorized + WWW-Authenticate: Bearer |
| Unknown key | 401 unauthorized + WWW-Authenticate: Bearer error="invalid_token" |
| Revoked key | 401 unauthorized — "This API key has been revoked" |
| Expired key | 401 unauthorized — "This API key has expired" |
| Key not allowed to call this utility | 403 forbidden |
Keep keys server-side. A key only ever sees the jobs it created.
The request envelope
Both ways of running a utility take the same body:
json
{
"input": { "url": "https://example.com/invoice.html" },
"metadata": { "invoiceId": "INV-1042" },
"callbackUrl": "https://billing.finnoto.com/hooks/du"
}| Field | Required | Description |
|---|---|---|
input | yes | The utility's own input — see each utility's page (HTML → PDF, Split PDF, Zip files, Unzip archive, XLS → XLSX). Validated before anything runs. |
metadata | no | Your own object (≤ 50 keys, key names ≤ 64 chars, ≤ 4 KB serialised). Stored on the job and echoed in every response and callback — use it for your correlation ids. |
callbackUrl | no, async only | An https:// URL that receives the finished job, signed. See Callbacks. Sending it to the sync route is a 400. |
Unknown top-level fields are rejected with 400 invalid_request.
Running a utility: sync or async
| Route | Behaviour |
|---|---|
POST /v1/utilities/{slug} | Sync. Runs the utility and answers with the finished job — 200 on success, or the error's status for a failed job. If it's still running after 90 s, answers 202 with the running job, a Location header and Retry-After: 5; the work continues and you fetch the result by id. |
POST /v1/utilities/{slug}/jobs | Async. Answers 202 immediately with { "id": "job_…", "status": "queued" }. A worker runs it; fetch the result by id, long-poll, or receive a callback. |
Prefer: respond-async header on the sync route | Turns the sync route into the async one (RFC 7240). |
Which one to use is covered in depth in Jobs, sync & async. Rule of thumb: sync for interactive, sub-minute work; async for batches, big inputs, or when you'd rather get a callback.
bash
# sync — one round trip
curl -X POST https://api.servicelabs.dev/v1/utilities/html-pdf \
-H "Authorization: Bearer $DU_API_KEY" -H "Content-Type: application/json" \
-d '{ "input": { "url": "https://example.com/invoice.html" } }'
# async — returns a job id at once
curl -X POST https://api.servicelabs.dev/v1/utilities/split-pdf/jobs \
-H "Authorization: Bearer $DU_API_KEY" -H "Content-Type: application/json" \
-d '{ "input": { "url": "s3://finnoto-data/uploads/statement.pdf" } }'The job resource
Every run — sync or async, success or failure — is a job with a stable id, and every response for a run returns the same shape:
json
{
"id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3",
"object": "job",
"utility": "split-pdf",
"mode": "async",
"status": "succeeded",
"createdAt": "2026-09-10T17:30:08.114Z",
"startedAt": "2026-09-10T17:30:08.151Z",
"finishedAt": "2026-09-10T17:30:09.714Z",
"timings": { "queueMs": 37, "upstreamMs": 1480, "storageMs": 23, "totalMs": 1600 },
"metadata": null,
"result": { "pageCount": 4, "files": [{ "url": "https://files.servicelabs.dev/…", "…": "…" }] },
"error": null,
"callback": null,
"links": { "self": "/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3" }
}| Field | Description |
|---|---|
id | job_ + ULID. Time-sortable, globally unique. |
utility | The utility slug. |
mode | sync or async — how the job was started. |
status | queued → running → succeeded | failed | cancelled. |
createdAt / startedAt / finishedAt | ISO-8601 UTC; null until reached. |
timings.queueMs | Accept → a worker picked it up (0 for sync). |
timings.upstreamMs | Time inside the utility itself. |
timings.storageMs | Time copying outputs to storage. |
timings.totalMs | Accept → finished, end to end. |
metadata | Your metadata, verbatim, or null. |
result | On success: { files: [...], ...extras }. null otherwise. |
error | On failure: { code, message, upstreamStatus? }. null otherwise. |
callback | { url, status: pending|delivered|failed, attempts } when a callbackUrl was given, else null. |
links.self | Where to fetch this job. |
Each entry in result.files is a file reference:
| Field | Description |
|---|---|
url | Download link (public, or presigned with urlExpiresAt). |
key | Storage key: <utility>/<YYYY-MM-DD>/<job id>/<file>. |
name | File name. |
size | Bytes, or null if unknown. |
contentType | MIME type, or null. |
urlExpiresAt | Only on presigned links: when the URL stops working. Fetching the job again returns a fresh one. |
pageNumber | Split PDF only — 1-indexed page. |
path | Unzip archive only — the entry's path inside the zip. |
More on links and retention in Files & storage.
Fetching results
bash
# one job — add ?wait=N to long-poll up to N seconds (max 60) until it finishes
curl "https://api.servicelabs.dev/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3?wait=30" \
-H "Authorization: Bearer $DU_API_KEY"
# this key's jobs, newest first
curl "https://api.servicelabs.dev/v1/jobs?utility=split-pdf&status=failed&limit=50" \
-H "Authorization: Bearer $DU_API_KEY"
# cancel — only while still queued
curl -X POST https://api.servicelabs.dev/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/cancel \
-H "Authorization: Bearer $DU_API_KEY"?wait returns as soon as the job reaches a terminal status, or when the wait runs out — whichever comes first — with the job as it is at that moment. Loop until status is terminal.
Status codes
| Route | Codes |
|---|---|
POST /v1/utilities/{slug} | 200 succeeded · 202 still running after 90 s (or Prefer: respond-async) · failed job: 422 / 502 / 504 / 500 per error.code · 400 · 401 · 403 · 404 unknown or disabled utility · 409 Idempotency-Key reused for another utility · 429 |
POST /v1/utilities/{slug}/jobs | 202 accepted · 400 · 401 · 403 · 404 · 409 · 429 |
GET /v1/jobs/{id} | 200 (whatever the job's status) · 404 no such job for this key |
GET /v1/jobs | 200 · 400 bad filter |
POST /v1/jobs/{id}/cancel | 200 cancelled · 409 not queued anymore · 404 |
GET /v1/utilities, GET /v1/utilities/{slug} | 200 · 404 |
A failed job still comes back as a job (with error filled in), so the body shape never changes. Request errors — bad input, bad key, rate limit — come back as the error envelope and create no job. The distinction and every code are in Conventions & errors.
Idempotency
Send an Idempotency-Key header (any string ≤ 200 chars — an order id, a UUID) on POST /v1/utilities/{slug} or …/jobs. A retry with the same key returns the original job instead of running the utility again, with Idempotent-Replayed: true:
bash
curl -X POST https://api.servicelabs.dev/v1/utilities/html-pdf/jobs \
-H "Authorization: Bearer $DU_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-INV-1042" \
-d '{ "input": { "url": "https://example.com/invoice.html" } }'- Keys are scoped to the API key that sent them, and remembered for as long as the job is retained.
- The key alone identifies the request: a retry with a different body and the same key still returns the original job. Reusing a key for a different utility is a
409 conflict. - Two concurrent requests with the same key resolve to one job.
Rate limits
Each API key has a per-minute budget (default 600 requests/minute, set per key by an admin). Every authenticated response carries:
| Header | Meaning |
|---|---|
RateLimit-Limit | The key's requests-per-minute budget. |
RateLimit-Remaining | Requests left in the current minute. |
RateLimit-Reset | Seconds until the window resets. |
Over budget → 429 rate_limited with Retry-After (seconds). Polling counts against the budget — prefer ?wait=30 long-polls or callbacks to tight loops.
Pagination
GET /v1/jobs is cursor-paginated, newest first:
| Query | Description |
|---|---|
limit | 1–100, default 25. |
cursor | The previous page's nextCursor. |
utility | Only this utility's jobs. |
status | queued, running, succeeded, failed or cancelled. |
json
{
"items": [{ "id": "job_…", "utility": "split-pdf", "status": "failed", "…": "…" }],
"nextCursor": "2026-09-10T17:30:08.114Z|job_…"
}Keep passing nextCursor back as cursor until it's null. Treat cursors as opaque. List items are job summaries (id, utility, mode, status, source, createdAt, finishedAt, totalMs, upstreamMs, errorCode, apiKeyName, userEmail, fileCount) — fetch the job for its files.
Discovering utilities
GET /v1/utilities lists the utilities this key may call, each with its input JSON Schema and an example input — enough to build a form or validate client-side. GET /v1/utilities/{slug} returns one.
OpenAPI
The whole API is described in OpenAPI 3.1, generated from the same schemas the server validates with — so it can't drift from the running API:
- Download:
/docs/openapi.yaml - Live:
GET https://api.servicelabs.dev/v1/openapi.json— no API key needed.
Import it into Postman, Insomnia or a client generator, or browse it rendered in API operations.