Skip to content

Quickstart

From zero to a rendered PDF and a split document in a few minutes. You need a console account and nothing else — no AWS access, no per-utility secrets.

1. Get access

Open the console at app.servicelabs.dev.

  • Finnoto staffContinue with Google with your @finnoto.com account. Your account is created on first sign-in.
  • Partners and everyone else — ask a Finnoto admin for an invite link. Open it, set a name and password (or use Google if the invite matches your Google email).

2. Create an API key

In the console go to API keys → Create key. Give it a name that says where it runs (billing-service, invoice-worker), optionally limit it to the utilities it needs, and create it.

You are shown two values once:

ValueLooks likeUsed for
API keydu_live_3f9a… (56 chars)Authorization: Bearer … on every API call
Callback signing secretwhsec_…Verifying callbacks for jobs this key creates

Only a hash of the key is stored — copy it into your secret manager now. The signing secret can be viewed again later on the API keys page.

bash
export DU_API_KEY="du_live_…"

3. See what your key can call

bash
curl https://api.servicelabs.dev/v1/utilities \
  -H "Authorization: Bearer $DU_API_KEY"
js
const res = await fetch('https://api.servicelabs.dev/v1/utilities', {
  headers: { Authorization: `Bearer ${process.env.DU_API_KEY}` },
})
const { items } = await res.json()
console.log(items.map((u) => u.slug)) // ['html-pdf', 'split-pdf', …]
python
import os, requests

res = requests.get(
    "https://api.servicelabs.dev/v1/utilities",
    headers={"Authorization": f"Bearer {os.environ['DU_API_KEY']}"},
)
print([u["slug"] for u in res.json()["items"]])

Each item carries the utility's full inputSchema (JSON Schema) and an exampleInput.

4. Your first sync call — HTML → PDF

A sync call waits for the result and answers 200 with the finished job. Every request body is the same envelope: { "input": { … }, "metadata": { … } }.

bash
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",
      "pdfOptions": { "format": "A4", "orientation": "portrait" }
    },
    "metadata": { "invoiceId": "INV-1042" }
  }'
js
const res = await fetch('https://api.servicelabs.dev/v1/utilities/html-pdf', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.DU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    input: { url: 'https://example.com/invoice.html', pdfOptions: { format: 'A4' } },
    metadata: { invoiceId: 'INV-1042' },
  }),
})
const job = await res.json()
if (job.status !== 'succeeded') throw new Error(job.error?.message ?? `HTTP ${res.status}`)
console.log(job.result.files[0].url)
python
import os, requests

res = requests.post(
    "https://api.servicelabs.dev/v1/utilities/html-pdf",
    headers={"Authorization": f"Bearer {os.environ['DU_API_KEY']}"},
    json={
        "input": {"url": "https://example.com/invoice.html", "pdfOptions": {"format": "A4"}},
        "metadata": {"invoiceId": "INV-1042"},
    },
    timeout=100,
)
job = res.json()
if job["status"] != "succeeded":
    raise RuntimeError(job.get("error") or res.status_code)
print(job["result"]["files"][0]["url"])
json
// → 200 OK
{
  "id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3",
  "object": "job",
  "utility": "html-pdf",
  "mode": "sync",
  "status": "succeeded",
  "createdAt": "2026-09-10T17:30:08.412Z",
  "startedAt": "2026-09-10T17:30:08.412Z",
  "finishedAt": "2026-09-10T17:30:10.754Z",
  "timings": { "queueMs": 0, "upstreamMs": 2140, "storageMs": 180, "totalMs": 2342 },
  "metadata": { "invoiceId": "INV-1042" },
  "result": {
    "files": [
      {
        "url": "https://files.servicelabs.dev/html-pdf/2026-09-10/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/20260910173009_22d0cf84ca.pdf",
        "key": "html-pdf/2026-09-10/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/20260910173009_22d0cf84ca.pdf",
        "name": "20260910173009_22d0cf84ca.pdf",
        "size": 115741,
        "contentType": "application/pdf"
      }
    ]
  },
  "error": null,
  "callback": null,
  "links": { "self": "/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3" }
}

A slow sync call doesn't fail

If a utility takes longer than 90 seconds, the sync call answers 202 Accepted with the still-running job and a Location header instead of timing out. The work continues — fetch the result by job id (step 6). Handle 202 in any sync caller whose inputs can be large.

5. Your first async call — Split PDF

An async call answers 202 immediately with a queued job. Use it for anything that may run long, or when you'd rather not hold a connection open.

bash
curl -X POST https://api.servicelabs.dev/v1/utilities/split-pdf/jobs \
  -H "Authorization: Bearer $DU_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: statement-2026-09" \
  -d '{ "input": { "url": "s3://finnoto-data/uploads/statement.pdf" } }'
js
const res = await fetch('https://api.servicelabs.dev/v1/utilities/split-pdf/jobs', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.DU_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'statement-2026-09',
  },
  body: JSON.stringify({ input: { url: 's3://finnoto-data/uploads/statement.pdf' } }),
})
const { id } = await res.json() // 202 → { id: 'job_…', status: 'queued', … }
python
res = requests.post(
    "https://api.servicelabs.dev/v1/utilities/split-pdf/jobs",
    headers={
        "Authorization": f"Bearer {os.environ['DU_API_KEY']}",
        "Idempotency-Key": "statement-2026-09",
    },
    json={"input": {"url": "s3://finnoto-data/uploads/statement.pdf"}},
)
job_id = res.json()["id"]
json
// → 202 Accepted   Location: /v1/jobs/job_01K7Z4…
{ "id": "job_01K7Z4…", "object": "job", "utility": "split-pdf", "mode": "async", "status": "queued", "result": null,  }

The Idempotency-Key makes retries safe: sending the same request again with the same key returns the same job instead of splitting the PDF twice.

6. Get the result by job id

GET /v1/jobs/{id} returns the job. Add ?wait=30 to hold the request open until the job finishes (up to 60 seconds), so you don't need a tight polling loop.

bash
curl "https://api.servicelabs.dev/v1/jobs/job_01K7Z4…?wait=30" \
  -H "Authorization: Bearer $DU_API_KEY"
js
async function waitForJob(id) {
  for (;;) {
    const res = await fetch(`https://api.servicelabs.dev/v1/jobs/${id}?wait=30`, {
      headers: { Authorization: `Bearer ${process.env.DU_API_KEY}` },
    })
    const job = await res.json()
    if (['succeeded', 'failed', 'cancelled'].includes(job.status)) return job
  }
}
const job = await waitForJob(id)
for (const f of job.result?.files ?? []) console.log(f.pageNumber, f.url)
python
def wait_for_job(job_id):
    while True:
        job = requests.get(
            f"https://api.servicelabs.dev/v1/jobs/{job_id}",
            params={"wait": 30},
            headers={"Authorization": f"Bearer {os.environ['DU_API_KEY']}"},
            timeout=70,
        ).json()
        if job["status"] in ("succeeded", "failed", "cancelled"):
            return job

job = wait_for_job(job_id)
for f in job["result"]["files"]:
    print(f["pageNumber"], f["url"])

Prefer not to poll at all? Pass a callbackUrl when you create the job and the finished job is POSTed to you, signed — see Callbacks.

7. Use the files

Every entry in result.files is a link on Cloudflare R2 plus its key, name, size and contentType (and pageNumber or path where it applies). Download what you need to keep — outputs are removed after the retention period. See Files & storage.

When something fails, the job's error tells you why:

json
// → 422 Unprocessable Entity
{ "id": "job_…", "status": "failed", "error": { "code": "upstream_rejected", "message": "Failed to download PDF: 404 Not Found", "upstreamStatus": 400 },  }

All codes are listed in Conventions & errors.

Next steps

ServiceLabs · a Finnoto company