Appearance
Jobs, sync & async
Every call to a utility creates a job — sync or async, from the API or the console. The job id (job_…) is how you fetch the result, find the call in the console, and correlate it with usage. This page is the complete model.
Two ways in, one object out
| Request | Waits? | Answers |
|---|---|---|
POST /v1/utilities/{slug} | Yes — up to 90 s | 200 with the finished job, or the error status with the failed job, or 202 if it's still running after 90 s |
POST /v1/utilities/{slug}/jobs | No | 202 with the queued job, immediately |
POST /v1/utilities/{slug} + Prefer: respond-async | No | Same as /jobs (RFC 7240) |
Both take the same body:
json
{
"input": { "url": "https://example.com/statement.pdf" },
"metadata": { "statementId": "ST-2026-09" },
"callbackUrl": "https://svc.finnoto.com/hooks/du"
}| Field | Required | Notes |
|---|---|---|
input | yes | The utility's input — its schema is on each utility page and in GET /v1/utilities/{slug}. Validated before anything runs; a bad input is a 400 and creates no job. |
metadata | no | Any JSON object you want back — ≤ 50 keys, keys ≤ 64 chars, ≤ 4 KB serialised. Echoed on the job and in callbacks. Use it for your own ids. |
callbackUrl | async only | Where to POST the finished job. Sending it to the sync route (without Prefer: respond-async) is a 400. See Callbacks. |
Lifecycle
async sync
POST …/jobs ──▶ queued ──▶ running ◀── POST /v1/utilities/{slug}
│ │
cancel ◀──┘ ├──▶ succeeded
│ └──▶ failed
▼
cancelled| Status | Meaning |
|---|---|
queued | Accepted, waiting for a worker (async only). The only status you can cancel. |
running | The utility is executing. Sync jobs are created directly in this state. |
succeeded | Finished; result.files holds the outputs. |
failed | Finished with an error — see error codes. |
cancelled | Cancelled while still queued. Never ran. |
succeeded, failed and cancelled are terminal — a job never changes after reaching one.
The job object
json
{
"id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3",
"object": "job",
"utility": "split-pdf",
"mode": "async",
"status": "succeeded",
"createdAt": "2026-09-10T17:30:08.412Z",
"startedAt": "2026-09-10T17:30:08.453Z",
"finishedAt": "2026-09-10T17:30:10.784Z",
"timings": { "queueMs": 41, "upstreamMs": 2140, "storageMs": 180, "totalMs": 2372 },
"metadata": { "statementId": "ST-2026-09" },
"result": {
"files": [
{
"url": "https://files.servicelabs.dev/split-pdf/2026-09-10/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/page-1.pdf",
"key": "split-pdf/2026-09-10/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/page-1.pdf",
"name": "page-1.pdf",
"size": 48213,
"contentType": "application/pdf",
"pageNumber": 1
}
],
"pageCount": 1
},
"error": null,
"callback": { "url": "https://svc.finnoto.com/hooks/du", "status": "delivered", "attempts": 1 },
"links": { "self": "/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3" }
}| Field | Type | Description |
|---|---|---|
id | string | job_ + a time-sortable ULID. |
object | "job" | Always job. |
utility | string | The utility slug. |
mode | sync | async | How it was requested. |
status | string | See Lifecycle. |
createdAt / startedAt / finishedAt | ISO 8601 | null | Accepted, first picked up, reached a terminal status. |
timings | object | See Timings. |
metadata | object | null | What you sent. |
result | object | null | { files: FileRef[], …extras } when succeeded. Extras are utility-specific (pageCount, fileCount). See Files. |
error | object | null | { code, message, upstreamStatus? } when failed. |
callback | object | null | { url, status: pending | delivered | failed, attempts } when a callbackUrl was given. |
links.self | string | Where to fetch this job. |
Sync in detail
- Success →
200and thesucceededjob. - Failure → the job's error status (
422forupstream_rejected,502forupstream_error,504forupstream_timeout, …) with the failed job as the body — so you always get the job id anderrorback, not a bare error envelope. - Slower than 90 s →
202 Accepted, the job withstatus: "running",Location: /v1/jobs/{id}andRetry-After: 5. The utility keeps running; fetch the result by id. The 90 s budget sits just under Cloudflare's 100 s edge timeout, so a sync call never dies on a slow utility — it degrades into an async one.
Always handle 202 on the sync route
A PDF that normally renders in 3 s can take minutes on a huge document. Code that only handles 200 will treat a perfectly healthy long job as an error.
Requests rejected before a job exists — bad input (400), missing or revoked key (401), key not allowed for this utility (403), unknown utility (404), rate limited (429) — return the plain error envelope and create no job.
Async in detail
POST /v1/utilities/{slug}/jobs validates the input, stores the job, queues it and answers 202 straight away with Location: /v1/jobs/{id}. A worker picks it up (typically within tens of milliseconds) and runs it exactly like a sync job. Async jobs can run up to the utility's full time limit (15 min; 5 min for XLS → XLSX).
Getting the result
Long-poll
GET /v1/jobs/{id}?wait=N holds the request until the job is terminal or N seconds pass (0–60, default 0), then returns the job either way.
bash
curl "https://api.servicelabs.dev/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3?wait=30" \
-H "Authorization: Bearer $DU_API_KEY"Loop on ?wait=30 until status is terminal. That's one request per 30 s of waiting — no tight polling needed. If you poll without wait, back off (1 s, 2 s, 5 s, then every 10 s): a key's rate limit covers GET requests too.
Callback
Pass callbackUrl and the finished job is POSTed to you, signed. See Callbacks.
Listing
GET /v1/jobs lists your key's jobs, newest first, with utility, status, limit (1–100, default 25) and cursor filters. Follow nextCursor until it's null. See List jobs.
Idempotency
Send an Idempotency-Key header (≤ 200 chars) on either route to make retries safe:
bash
curl -X POST https://api.servicelabs.dev/v1/utilities/zip-files/jobs \
-H "Authorization: Bearer $DU_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: payout-batch-2026-09-10" \
-d '{"input":{"files":[{"url":"https://example.com/a.pdf","fileName":"a.pdf"}]}}'- Keys are scoped to your API key. Two keys can use the same value independently.
- Resending with the same key returns the original job (whatever its status now) with
Idempotent-Replayed: true— nothing runs twice. On the sync route a replay of a finished job returns its final status; of a running one,202. - Reusing a key for a different utility is a
409 conflict. - The body is not compared — same key, same job.
- Keys live as long as the job does (see Visibility and retention).
Cancelling
POST /v1/jobs/{id}/cancel cancels a job that is still queued and returns it with status: "cancelled". Once a worker has started it, the utility is already running and can't be stopped — cancelling then answers 409 conflict. Cancelled jobs send no callback.
Timings
Every job records where its time went, in milliseconds:
| Field | Measures | Notes |
|---|---|---|
queueMs | Accept → a worker picked it up | 0 for sync jobs. |
upstreamMs | The utility's own execution (the Lambda invocation) | For the final attempt. |
storageMs | Copying the outputs to R2 | Grows with file count and size. |
totalMs | Accept → terminal status | End-to-end, what your caller experienced. |
totalMs − queueMs − upstreamMs − storageMs is gateway overhead (validation, bookkeeping, retries' back-off). The console's job page draws this as a stacked bar. Aggregated latency is covered in Usage & latency.
Retries
The gateway retries only failures to reach the utility, never a utility's own answer:
| Retried (up to 3 attempts, exponential back-off) | Not retried — final |
|---|---|
Lambda throttling / TooManyRequestsException | The utility answered 4xx (bad file, 404 on your URL, invalid options) |
Upstream 429, 502, 503 | The utility answered 500 |
| Lambda service errors, network errors | The utility crashed or hit its time limit |
Why so strict: the utilities have side effects (they write files) and a bad input fails the same way every time — retrying would only double the cost and the latency. A utility answering 500 usually means something about your input broke it; the error message says what. Sync jobs retry in-process after 1 s and 2 s; async jobs after 5 s and 10 s. Each retry appears as a retry event on the job's timeline in the console.
The lost error
If the process running a job dies mid-run (a deploy, a crash), the job can't finish itself:
- Async jobs are re-delivered to another worker automatically within about a minute and run again.
- Sync jobs still running when the API shuts down are handed to a worker, which runs them again; the caller already got a
202and finds the result by id. - Anything still
runningwell past the longest time limit (about 17 minutes) is markedfailedwith codelostby a janitor that runs every 5 minutes. Async jobs with acallbackUrlget a callback for it.
A lost job is safe to resubmit.
Visibility and retention
- An API key sees only the jobs it created —
GET /v1/jobs/{id}for another key's job is a404. - In the console, staff see every job; external (guest) accounts see only jobs from their own keys and their own playground runs. See API keys & access.
- Job records — input, result, timeline — are kept for 90 days, then deleted. Usage and latency rollups are kept indefinitely, so historic usage survives. The files themselves follow the storage retention in Files & storage.