Skip to content

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.

Slughtml-pdf
SyncPOST /v1/utilities/html-pdfrunHtmlPdf
AsyncPOST /v1/utilities/html-pdf/jobscreateHtmlPdfJob
Max duration900 s (15 min)
Outputone 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:

FieldTypeRequiredDescription
urlstringyesThe HTML document to render. http://, https:// or s3://, ≤ 4096 chars.
pdfOptionsobjectnoPage 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.

KeyTypeDefaultNotes
formatstringA4Letter, Legal, Tabloid, Ledger, A0A6 (case-insensitive).
widthnumber | stringCustom width. A number is pixels; a string may carry px, in, cm or mm ("21cm", "8.5in"). Must be sent with height.
heightnumber | stringCustom height. Must be sent with width.
orientationstringportraitportrait or landscape (case-insensitive).
landscapebooleanBoolean spelling of orientation — the same Chromium flag.
scalenumber10.12.
preferCSSPageSizebooleanfalseTake the page size from the document's @page rule.
printBackgroundbooleantruePrint background colours and images.
marginobject1cm each side{ top, right, bottom, left }, each a number (px) or a length string.

Rules enforced at the gateway (→ 400, no job created):

  • format and width/height are mutually exclusive — Chromium would silently prefer format, so sending both is refused.
  • width and height must be given together.

Rule enforced by the renderer (→ 422 upstream_rejected, a failed job):

  • orientation and landscape may 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 sendResult
No pdfOptions (or {})The document's own @page size, unchanged.
Only formatDimensions from format. A document @page rule can still set orientation (a template with @page { size: A4 landscape } stays landscape).
width/height, orientation or landscapeYour geometry wins: the document's @page size is overridden (its margins survive).
preferCSSPageSize: trueThe 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.ready plus 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 pdfOptions fails in milliseconds and costs nothing.

Result

result.files always holds exactly one file:

FieldExample
urlhttps://files.servicelabs.dev/html-pdf/2026-09-10/job_…/20260910173009_22d0cf84ca.pdf
keyhtml-pdf/2026-09-10/job_…/20260910173009_22d0cf84ca.pdf
name20260910173009_22d0cf84ca.pdf — a generated yyyymmddhhmmss_<10 hex>.pdf name
sizebytes
contentTypeapplication/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

SituationOutcome
url isn't http(s):// or s3://; unknown/contradictory pdfOptions key; bad format400 invalid_request — no job
The HTML can't be downloaded (404, DNS, private URL)failed job, 422 upstream_rejected, upstreamStatus: 400
orientation contradicts landscapefailed 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 sfailed job, 504 upstream_timeout

Failed jobs keep the renderer's own message in error.message. The full error table is in Conventions & errors.

ServiceLabs · a Finnoto company