Appearance
HTML → PDF
Renders an HTML document to PDF in headless Chromium — page size, orientation, scale and margins included. The source can be any public http(s):// URL or an s3:// object; the PDF comes back as an R2 link on the job.
| Slug | html-pdf |
| Sync | POST /v1/utilities/html-pdf → runHtmlPdf |
| Async | POST /v1/utilities/html-pdf/jobs → createHtmlPdfJob |
| Max duration | 900 s (15 min) |
| Output | one application/pdf file |
Quick example
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?.code}: ${job.error?.message}`)
console.log(job.result.files[0].url) // → https://files.servicelabs.dev/html-pdf/…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"}}},
timeout=100,
)
job = res.json()
print(job["status"], job["result"]["files"][0]["url"] if job["result"] else job["error"])json
// → 200 OK
{
"id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3",
"object": "job",
"utility": "html-pdf",
"mode": "sync",
"status": "succeeded",
"createdAt": "2026-09-10T17:30:08.114Z",
"startedAt": "2026-09-10T17:30:08.114Z",
"finishedAt": "2026-09-10T17:30:10.456Z",
"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" }
}Input
The request body is the standard envelope — { "input": …, "metadata"?: …, "callbackUrl"?: … } — with this input:
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | The HTML document to render. http://, https:// or s3://, ≤ 4096 chars. |
pdfOptions | object | no | Page geometry (below). Omit it to honour the document's own @page size. |
pdfOptions
Unknown keys are rejected with 400 invalid_request — the whole object is validated at the gateway before anything runs.
| Key | Type | Default | Notes |
|---|---|---|---|
format | string | A4 | Letter, Legal, Tabloid, Ledger, A0–A6 (case-insensitive). |
width | number | string | — | Custom width. A number is pixels; a string may carry px, in, cm or mm ("21cm", "8.5in"). Must be sent with height. |
height | number | string | — | Custom height. Must be sent with width. |
orientation | string | portrait | portrait or landscape (case-insensitive). |
landscape | boolean | — | Boolean spelling of orientation — the same Chromium flag. |
scale | number | 1 | 0.1 – 2. |
preferCSSPageSize | boolean | false | Take the page size from the document's @page rule. |
printBackground | boolean | true | Print background colours and images. |
margin | object | 1cm each side | { top, right, bottom, left }, each a number (px) or a length string. |
Rules enforced at the gateway (→ 400, no job created):
formatandwidth/heightare mutually exclusive — Chromium would silently preferformat, so sending both is refused.widthandheightmust be given together.
Rule enforced by the renderer (→ 422 upstream_rejected, a failed job):
orientationandlandscapemay both be sent only if they agree.{ "orientation": "portrait", "landscape": true }is rejected.
Give dimensions in portrait order
orientation: "landscape" swaps the page's dimensions for a named format and for a custom width/height. So describe the page upright and flip orientation — { "width": "4in", "height": "6in", "orientation": "landscape" } renders a 6 × 4 in page. Don't swap width and height yourself.
How the page size is decided
Most HTML templates declare their own size, e.g. @page { size: A4; margin: 10mm; }. The renderer resolves the conflict like this:
| You send | Result |
|---|---|
No pdfOptions (or {}) | The document's own @page size, unchanged. |
Only format | Dimensions from format. A document @page rule can still set orientation (a template with @page { size: A4 landscape } stays landscape). |
width/height, orientation or landscape | Your geometry wins: the document's @page size is overridden (its margins survive). |
preferCSSPageSize: true | The document's @page rule wins outright. |
Rendering behaviour
- Fragments are completed. HTML without an
<html>tag is wrapped in a full document with a UTF-8 charset and the Noto Sans font, so symbols like₹render. - Fonts get time to load. The page is loaded until the network is idle, then the renderer waits for
document.fonts.readyplus 500 ms. Very slow font CDNs can still fall back — self-host critical fonts or inline them. s3://sources are fetched over public HTTPS first and, if that fails, read directly from S3 — so private objects in the utilities' bucket work too.- Page options are validated before Chromium starts, so a bad
pdfOptionsfails in milliseconds and costs nothing.
Result
result.files always holds exactly one file:
| Field | Example |
|---|---|
url | https://files.servicelabs.dev/html-pdf/2026-09-10/job_…/20260910173009_22d0cf84ca.pdf |
key | html-pdf/2026-09-10/job_…/20260910173009_22d0cf84ca.pdf |
name | 20260910173009_22d0cf84ca.pdf — a generated yyyymmddhhmmss_<10 hex>.pdf name |
size | bytes |
contentType | application/pdf |
There are no utility-specific extras. See Files & storage for URL lifetime and retention.
Async + poll
Rendering is usually a few seconds, but large documents with many images can take longer than the 90 s sync budget. Use the async route (or Prefer: respond-async) for batch runs:
bash
# 1. start — returns immediately
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": "s3://finnoto-data/templates/INV-1042.html" },
"callbackUrl": "https://billing.finnoto.com/hooks/du" }'
# → 202 { "id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3", "status": "queued", … }
# 2. fetch the result by id (long-polls up to 30 s)
curl "https://api.servicelabs.dev/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3?wait=30" \
-H "Authorization: Bearer $DU_API_KEY"With a callbackUrl, the finished job is also POSTed to you, signed — see Callbacks.
Errors specific to this utility
| Situation | Outcome |
|---|---|
url isn't http(s):// or s3://; unknown/contradictory pdfOptions key; bad format | 400 invalid_request — no job |
| The HTML can't be downloaded (404, DNS, private URL) | failed job, 422 upstream_rejected, upstreamStatus: 400 |
orientation contradicts landscape | failed job, 422 upstream_rejected |
| The render crashed (e.g. out of memory on a huge page) | failed job, 502 upstream_error |
| The render exceeded 900 s | failed job, 504 upstream_timeout |
Failed jobs keep the renderer's own message in error.message. The full error table is in Conventions & errors.