Skip to content

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

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

SituationResponse
No Authorization header, or not Bearer <key>401 unauthorized + WWW-Authenticate: Bearer
Unknown key401 unauthorized + WWW-Authenticate: Bearer error="invalid_token"
Revoked key401 unauthorized — "This API key has been revoked"
Expired key401 unauthorized — "This API key has expired"
Key not allowed to call this utility403 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"
}
FieldRequiredDescription
inputyesThe utility's own input — see each utility's page (HTML → PDF, Split PDF, Zip files, Unzip archive, XLS → XLSX). Validated before anything runs.
metadatanoYour 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.
callbackUrlno, async onlyAn 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

RouteBehaviour
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}/jobsAsync. 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 routeTurns 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" }
}
FieldDescription
idjob_ + ULID. Time-sortable, globally unique.
utilityThe utility slug.
modesync or async — how the job was started.
statusqueuedrunningsucceeded | failed | cancelled.
createdAt / startedAt / finishedAtISO-8601 UTC; null until reached.
timings.queueMsAccept → a worker picked it up (0 for sync).
timings.upstreamMsTime inside the utility itself.
timings.storageMsTime copying outputs to storage.
timings.totalMsAccept → finished, end to end.
metadataYour metadata, verbatim, or null.
resultOn success: { files: [...], ...extras }. null otherwise.
errorOn failure: { code, message, upstreamStatus? }. null otherwise.
callback{ url, status: pending|delivered|failed, attempts } when a callbackUrl was given, else null.
links.selfWhere to fetch this job.

Each entry in result.files is a file reference:

FieldDescription
urlDownload link (public, or presigned with urlExpiresAt).
keyStorage key: <utility>/<YYYY-MM-DD>/<job id>/<file>.
nameFile name.
sizeBytes, or null if unknown.
contentTypeMIME type, or null.
urlExpiresAtOnly on presigned links: when the URL stops working. Fetching the job again returns a fresh one.
pageNumberSplit PDF only — 1-indexed page.
pathUnzip 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

RouteCodes
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}/jobs202 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/jobs200 · 400 bad filter
POST /v1/jobs/{id}/cancel200 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:

HeaderMeaning
RateLimit-LimitThe key's requests-per-minute budget.
RateLimit-RemainingRequests left in the current minute.
RateLimit-ResetSeconds 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:

QueryDescription
limit1–100, default 25.
cursorThe previous page's nextCursor.
utilityOnly this utility's jobs.
statusqueued, 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.

ServiceLabs · a Finnoto company