HTML to PDF
Send a link to an HTML document. Get back a PDF printed by headless Chromium, at the size you ask for or the size the document asks for.
SyncPOST /v1/utilities/html-pdfWaits up to 90 s for the finished job.
AsyncPOST /v1/utilities/html-pdf/jobsAnswers 202 with the job id at once.
What it’s for
-
Invoices and receiptsPrint the template you already render on screen. Its
@pagerules, web fonts and symbols like ₹ come through. - Statements and reportsLong documents with many images go through the async route and come back as one file.
-
Labels, tickets, odd sizesSet width and height in px, in, cm or mm, and turn the page with
orientation, without touching the template. -
Templates kept in S3Point at an
s3://URL. If the public fetch fails, the object is read directly from the utilities’ bucket.
How it runs
-
FetchDownloads the HTML from your URL. A fragment without an
<html>tag is wrapped in a UTF-8 document with Noto Sans. -
SettleWaits for the network to go idle, then for
document.fonts.ready, then 500 ms more. -
PrintChromium prints with your
pdfOptions. Without them, the document’s own@pagesize wins. -
StoreOne PDF, linked on the job as
result.files[0].
pdfOptions is checked before Chromium starts. A bad option fails in
milliseconds with 400, and no job is created.
Input
Send these fields as input in the request body. Anything not listed is
refused with 400.
| Field | Type | Required or optional | Accepts |
|---|---|---|---|
url |
string |
Required |
http://, https:// or s3:// URL, 1–4,096
characters. The HTML document to render.
|
pdfOptions |
object |
Optional |
Page geometry. Leave it out to keep the document’s own
@page size. Unknown keys are refused.
|
pdfOptions.format
|
string |
Optional |
Letter, Legal, Tabloid,
Ledger or A0–A6, any case. Default
A4. Not together with width and height.
|
pdfOptions.width
|
number | string |
with height |
A number is pixels. A string of 1–32 characters may carry px, in, cm or mm:
"21cm", "8.5in".
|
pdfOptions.height
|
number | string |
with width |
As width. Describe the page upright and use orientation to turn
it.
|
pdfOptions.orientation
|
string |
Optional |
portrait or landscape, any case. Swaps the page’s
dimensions.
|
pdfOptions.landscape
|
boolean |
Optional |
The same flag as orientation. If you send both, they must agree.
|
pdfOptions.scale
|
number |
Optional | 0.1 to 2. Default 1. |
pdfOptions.preferCSSPageSize
|
boolean |
Optional |
Let the document’s @page rule win outright. Default
false.
|
pdfOptions.printBackground
|
boolean |
Optional | Print background colours and images. Default true. |
pdfOptions.margin
|
object |
Optional |
{ top, right, bottom, left }, each a number (px) or a length
string. Default 1cm on every side.
|
How the page size is decided
| You send | The PDF gets |
|---|---|
No pdfOptions, or {} |
The document’s own @page size, unchanged. |
Only format |
Dimensions from format. A document @page rule can
still set the orientation.
|
width/height, orientation or
landscape
|
Your geometry. The document’s @page size is overridden; its
margins survive.
|
preferCSSPageSize: true |
The document’s @page rule, outright. |
The request body
| Field | Required or optional | What it is |
|---|---|---|
input |
Required | The fields above. |
metadata |
Optional | Your own key–value pairs, returned on the job. At most 50 keys, 4 KB serialised. |
callbackUrl |
Optional | Async route only. The finished job is POSTed here, signed. Up to 2,048 characters. |
What comes back
| Field | Value |
|---|---|
files[].url
|
A link to the file. |
result.files
|
Exactly one file. |
files[].name
|
A generated yyyymmddhhmmss_<10 hex>.pdf name. |
files[].contentType
|
application/pdf |
Example
curl 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",
"printBackground": true,
"margin": { "top": "12mm", "bottom": "12mm" }
}
}
}'
# 1. Start the job. Answers 202 straight away.
curl https://api.servicelabs.dev/v1/utilities/html-pdf/jobs \
-H "Authorization: Bearer $DU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: html-pdf-0001" \
-d '{
"input": {
"url": "https://example.com/invoice.html",
"pdfOptions": {
"format": "A4",
"printBackground": true,
"margin": { "top": "12mm", "bottom": "12mm" }
}
},
"callbackUrl": "https://example.com/hooks/servicelabs"
}'
# 2. Collect it by id. Long-polls for up to 60 s.
curl "https://api.servicelabs.dev/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3?wait=60" \
-H "Authorization: Bearer $DU_API_KEY"
const API = 'https://api.servicelabs.dev/v1'
const headers = {
Authorization: `Bearer ${process.env.DU_API_KEY}`,
'Content-Type': 'application/json',
}
// Sync: holds the connection for up to 90 s
const res = await fetch(`${API}/utilities/html-pdf`, {
method: 'POST',
headers,
body: JSON.stringify({ input: {
url: 'https://example.com/invoice.html',
pdfOptions: {
format: 'A4',
printBackground: true,
margin: { top: '12mm', bottom: '12mm' },
},
} }),
})
if (res.status === 400) throw new Error((await res.json()).error.message)
let job = await res.json()
// Still running after 90 s? It carries on. Collect it by id.
while (job.status === 'queued' || job.status === 'running') {
job = await (await fetch(`${API}/jobs/${job.id}?wait=60`, { headers })).json()
}
if (job.status === 'succeeded') console.log(job.result.files.map((f) => f.url))
else console.error(job.error.code, job.error.message)
{
"id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3",
"object": "job",
"utility": "html-pdf",
"mode": "sync",
"status": "succeeded",
"createdAt": "2026-09-10T17:30:06.650Z",
"finishedAt": "2026-09-10T17:30:09.009Z",
"timings": { "queueMs": 0, "upstreamMs": 2140, "storageMs": 196, "totalMs": 2359 },
"result": {
"files": [
{
"url": "https://…/20260910173009_22d0cf84ca.pdf",
"name": "20260910173009_22d0cf84ca.pdf",
"size": 115741,
"contentType": "application/pdf"
}
]
},
"error": null,
"links": {
"self": "/v1/jobs/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3"
}
}
An example job. File links point at the stored output; download anything you need to keep.
{
"error": {
"code": "invalid_request",
"message": "input failed validation for html-pdf",
"details": [
{
"path": "input.url",
"message": "must be an http://, https:// or s3:// URL"
}
]
}
}
Input is checked before anything runs. A 400 creates no job and costs nothing.
Limits
| Limit | Value |
|---|---|
| Runs up to | 900 s (15 min) |
| Sources | http://, https:// or s3:// |
| Output | One application/pdf file |
| Page load | Network idle, then fonts, then 500 ms |
| Defaults | Backgrounds printed, 1cm margins |
| Sync wait | 90 s, then 202 with the job id |
| Long-poll | Up to 60 s per GET /v1/jobs/{id}?wait= |
| Rate limit | Per key; 600 requests a minute unless set otherwise |
| Output files | Kept for a limited period; download what you need |
When it fails
A 400 means nothing ran. Everything else is a failed job with
status: "failed", an error code and the utility’s own message.
| Situation | Status | Code | Job |
|---|---|---|---|
url isn’t http(s):// or s3://; an
unknown or contradictory pdfOptions key; a bad
format
|
400 | invalid_request |
No job |
| The HTML can’t be downloaded (404, DNS, a private URL) | 422 | upstream_rejected |
Failed job |
orientation contradicts landscape |
422 | upstream_rejected |
Failed job |
| The render crashed, e.g. out of memory on a huge page | 502 | upstream_error |
Failed job |
| The render ran past 900 s | 504 | upstream_timeout |
Failed job |
Any call can also get 401 unauthorized, 403 forbidden (the
key isn’t allowed this utility) or 429 rate_limited. The full list is in
Conventions & errors.
Reference
- HTML to PDF guideEvery option, with worked examples.
-
runHtmlPdf
POST /v1/utilities/html-pdf -
createHtmlPdfJob
POST /v1/utilities/html-pdf/jobs -
getJob
GET /v1/jobs/{id} - JobsStatuses, timings, retries and cancelling.
- CallbacksVerifying the signature on a finished job.
Also in service
Try HTML to PDF on your own file.
The console’s playground runs html-pdf against any URL you give it and shows the job it returns.