Appearance
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 staff — Continue with Google with your
@finnoto.comaccount. 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:
| Value | Looks like | Used for |
|---|---|---|
| API key | du_live_3f9a… (56 chars) | Authorization: Bearer … on every API call |
| Callback signing secret | whsec_… | 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
- Jobs, sync & async — the full lifecycle, idempotency, cancelling and retries.
- Callbacks — receive and verify signed results.
- Utility pages: HTML → PDF · Split PDF · Zip · Unzip · XLS → XLSX.
- Moving an existing integration? Migrating from the Lambda URLs.