Skip to content

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

RequestWaits?Answers
POST /v1/utilities/{slug}Yes — up to 90 s200 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}/jobsNo202 with the queued job, immediately
POST /v1/utilities/{slug} + Prefer: respond-asyncNoSame 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"
}
FieldRequiredNotes
inputyesThe 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.
metadatanoAny 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.
callbackUrlasync onlyWhere 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
StatusMeaning
queuedAccepted, waiting for a worker (async only). The only status you can cancel.
runningThe utility is executing. Sync jobs are created directly in this state.
succeededFinished; result.files holds the outputs.
failedFinished with an error — see error codes.
cancelledCancelled 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" }
}
FieldTypeDescription
idstringjob_ + a time-sortable ULID.
object"job"Always job.
utilitystringThe utility slug.
modesync | asyncHow it was requested.
statusstringSee Lifecycle.
createdAt / startedAt / finishedAtISO 8601 | nullAccepted, first picked up, reached a terminal status.
timingsobjectSee Timings.
metadataobject | nullWhat you sent.
resultobject | null{ files: FileRef[], …extras } when succeeded. Extras are utility-specific (pageCount, fileCount). See Files.
errorobject | null{ code, message, upstreamStatus? } when failed.
callbackobject | null{ url, status: pending | delivered | failed, attempts } when a callbackUrl was given.
links.selfstringWhere to fetch this job.

Sync in detail

  • Success200 and the succeeded job.
  • Failure → the job's error status (422 for upstream_rejected, 502 for upstream_error, 504 for upstream_timeout, …) with the failed job as the body — so you always get the job id and error back, not a bare error envelope.
  • Slower than 90 s202 Accepted, the job with status: "running", Location: /v1/jobs/{id} and Retry-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 (060, 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:

FieldMeasuresNotes
queueMsAccept → a worker picked it up0 for sync jobs.
upstreamMsThe utility's own execution (the Lambda invocation)For the final attempt.
storageMsCopying the outputs to R2Grows with file count and size.
totalMsAccept → terminal statusEnd-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 / TooManyRequestsExceptionThe utility answered 4xx (bad file, 404 on your URL, invalid options)
Upstream 429, 502, 503The utility answered 500
Lambda service errors, network errorsThe 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 202 and finds the result by id.
  • Anything still running well past the longest time limit (about 17 minutes) is marked failed with code lost by a janitor that runs every 5 minutes. Async jobs with a callbackUrl get a callback for it.

A lost job is safe to resubmit.

Visibility and retention

  • An API key sees only the jobs it createdGET /v1/jobs/{id} for another key's job is a 404.
  • 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.

ServiceLabs · a Finnoto company