HTML to PDF v1.0 2 credits / call
Render HTML into a paginated PDF — page size, margins, running headers/footers, metadata and password protection.
- Base path
- /api/v1/html-to-pdf
- Methods
- GET or POST — parameters are read identically from the query string or a JSON body
- Auth
- X-Api-Key header (never Authorization: Bearer)
- Price
- 2–8 credits per call, by endpoint · about $0.0020 at 1,000 credits/USD
- Free tier
- 40 credits, refilled every month — drawn down before your paid balance
- Test mode
- Supported — ksty_test_ keys call this API free of charge
- Rate limit
- 60 calls/minute per key by default, raisable per key
HTML to PDF API
Turns HTML into a real, paginated PDF. Send the markup with html, or a url to fetch, and you get back a document with the page size, margins, running headers and footers you asked for — the things a browser's print dialog does, but as an API call you can make from a server with no browser on it.
Rendering is synchronous: one request in, one finished PDF out. There is no job to poll and no file to clean up afterwards.
Your first call
POST /api/v1/html-to-pdf
X-Api-Key: ksty_test_...
Content-Type: application/json
{"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>", "filename": "invoice-1042.pdf"}
{
"filename": "invoice-1042.pdf",
"content_type": "application/pdf",
"bytes": 12894,
"pages": 1,
"page_size": "A4",
"orientation": "portrait",
"encrypted": false,
"assets_blocked": [],
"pdf_base64": "JVBERi0xLjQKJeLj..."
}
Getting the file, not JSON
The default JSON response carries the document in pdf_base64 — decode it and write it to disk:
$pdf = base64_decode($response['pdf_base64']);
file_put_contents($response['filename'], $pdf);
If you would rather have the bytes straight, send "output": "binary" (or an Accept: application/pdf header) and the response body is the PDF, with Content-Type: application/pdf, a Content-Disposition naming your file, and X-Pdf-Pages / X-Pdf-Bytes headers:
curl -X POST https://ksty.ch/api/v1/html-to-pdf \
-H "X-Api-Key: ksty_live_..." -H "Content-Type: application/json" \
-d '{"html":"<h1>Hi</h1>","output":"binary"}' -o out.pdf
Errors are always JSON, even in binary mode — check the content type or the status code before writing the body to a file.
Page setup
page_size—A3,A4(default),A5,Letter,Legal,TabloidorLedger.orientation—portrait(default) orlandscape.margin— millimetres. A single number sets all four sides; an object sets them individually, and any side you leave out keeps its default ({"top":16,"right":15,"bottom":16,"left":15}).
{"html": "…", "page_size": "Letter", "orientation": "landscape", "margin": {"top": 25, "bottom": 25}}
Headers, footers and page numbers
header_html and footer_html are small HTML fragments repeated on every page, laid out inside the top and bottom margins. Four placeholders are substituted as the document paginates:
| Placeholder | Becomes |
|---|---|
{PAGENO} |
the current page number |
{nb} |
the total page count |
{DATE j-m-Y} |
today's date, in any PHP date format |
{PAGENO}/{nb} |
the usual "3/12" pairing |
{
"html": "<h1>Quarterly report</h1>…",
"header_html": "<div style='text-align:right;font-size:9pt;color:#888'>Acme Inc — Q3</div>",
"footer_html": "<div style='text-align:center;font-size:9pt'>Page {PAGENO} of {nb}</div>"
}
Give a header or footer room to sit in — with the default 16 mm top margin there is space for one line. If your header wraps onto two, raise margin.top to match or the body text will start underneath it.
Styling
Inline style attributes and <style> blocks in your HTML both work, and css adds a stylesheet on top of them — handy when the markup comes from somewhere you don't control:
{"html": "<table>…</table>", "css": "body{font-family:sans-serif} table{width:100%;border-collapse:collapse} td{border:1px solid #ddd;padding:6px}"}
Supported CSS is print-oriented: the box model, tables, colours, borders, backgrounds, fonts, and the page-break properties (page-break-before, page-break-after, page-break-inside: avoid) — including <div style="page-break-after:always"></div> to force a new page. Flexbox and CSS grid are not supported; use tables or floats for print layout.
Assets: what can and cannot be loaded
Because you supply the markup, you also supply every URL in it — so the renderer is strict about what it will go and fetch:
- Local files are never read. A
srcpointing at a path on the gateway's disk resolves to nothing. - Remote URLs are off by default. Pass
remote_assets: trueto allowhttp(s)images and stylesheets. Each one is then checked (public addresses only, redirects re-checked, 5 s timeout, 5 MB each, 25 per document). data:URIs always work. They are already in your payload — nothing is fetched.
Anything refused is skipped, not fatal: the PDF still renders and every skipped asset is listed in assets_blocked with the reason, so a missing logo is something you can see rather than guess at.
{"assets_blocked": [{"url": "/var/www/logo.png", "reason": "local file access is not permitted"}]}
For a logo or a signature image, embedding it as a data: URI is both faster and more reliable than a URL — nothing to fetch, nothing to time out.
Relative paths need a base
A page written to be served from a web root refers to its assets relatively — assets/css/main.css, images/pic01.jpg. Those are meaningless on their own, so base_url says where they live:
{"html": "…<link rel=\"stylesheet\" href=\"assets/css/main.css\">…", "base_url": "https://example.com/site/", "remote_assets": true}
Every relative path is then resolved against it (https://example.com/site/assets/css/main.css) and fetched under the rules above. When you use url, base_url defaults to that URL, so a fetched page's own assets resolve without you saying anything. Without a base, relative assets resolve to nothing and the document renders as unstyled text — which is the usual reason a saved-from-the-browser HTML file comes back looking bare.
Rendering a live page
url fetches a page and renders it. The fetch is a single guarded GET (public http(s) only, ≤3 redirects each re-checked, 8 s timeout, 3 MB cap) and a failure costs you nothing.
{"url": "https://example.com", "remote_assets": true}
Note that a page fetched this way is usually not styled the way you see it in a browser: its CSS and images are external, so they only load with remote_assets: true, and anything drawn by JavaScript after load was never in the HTML at all. For pages that render client-side, get the final HTML yourself and pass it as html.
Document properties and protection
title, author and subject populate the PDF's info panel. password encrypts the document — readers prompt for it on open, and printing and copying stay allowed:
{"html": "…", "title": "Payslip — March", "author": "Acme Payroll", "password": "s3cret"}
Lost passwords cannot be recovered. The document is encrypted with the password you send and never stored, so re-rendering is the only way back in.
Merging several documents into one PDF
documents takes a list and renders it as a single PDF, in order, with a page break between each:
{
"documents": [
{"html": "<h1>Cover</h1>"},
{"html": "<h1>Statement</h1><table>…</table>", "css": "table{width:100%}"},
{"url": "https://example.com/terms"}
],
"footer_html": "<div style='text-align:center'>Page {PAGENO} of {nb}</div>"
}
Each entry takes the same html / url pair as a single call, plus an optional css; a bare string is shorthand for {"html": "…"}. The response adds a documents count.
The reason to merge here rather than make three calls and stitch the results yourself is pagination: {PAGENO} and {nb} run continuously across the whole set, so page 4 of 9 really says "4 of 9". Page setup, margins, headers and footers are shared — once merged there is one document, so one cascade, and a per-document css is appended to the shared stylesheet rather than scoped to its section.
Up to 10 documents per call, and the combined HTML still has to fit the 2 MB input budget.
Page images
html-to-pdf.image renders the document exactly as the PDF endpoints do, then returns one page as a PNG — for thumbnails, previews in your own UI, or anywhere shipping a PDF viewer is more trouble than it is worth:
POST /api/v1/html-to-pdf.image
{"html": "<h1>Invoice #1042</h1>", "page": 1, "width": 900}
{"page":1,"pages":1,"width":900,"height":1273,"bytes":139014,"content_type":"image/png","image_base64":"iVBORw0KGgo…"}
page— which page, 1-based (default 1). Asking for a page the document does not have is a freeinvalid_request, and the error tells you how many there are.width— 80 to 2000 px; the height follows the page's aspect ratio.output: "binary"streams the raw PNG, same as it does for the PDF.
It costs 3 credits rather than 2: the document is rendered and rasterised.
Page images need a PDF-capable ImageMagick on the server. Where that is missing — or where its
policy.xmldisables the PDF coder, which is a common hardening default — the endpoint answerspreview_unavailable(503) and does not charge you. The PDF endpoints are unaffected, so a503here is never a sign that rendering itself is broken.
Rendering it exactly as the browser draws it
The default engine is mPDF: fast, pure PHP, print-oriented — and, as the Limits say, no JavaScript and no flexbox or grid. When you need the PDF to look like the page looks in a browser, html-to-pdf.browser renders through headless Chrome instead:
POST /api/v1/html-to-pdf.browser
{"html": "<div style='display:grid;grid-template-columns:1fr 1fr'>…</div>", "wait_ms": 250}
Everything a browser does, it does: flexbox and grid, gradients, box-shadow, custom properties, and your scripts run before the page is printed. The response is identical in shape to the mPDF one, with "engine": "chrome" so you can tell them apart.
It costs 8 credits rather than 2, because a browser process is started for the call. Expect roughly 1–2 seconds.
wait_ms— extra settle time for scripts that finish afterload(default 250, max 3000). Raise it if a chart or a font renders a moment late.header_html/footer_htmlwork here too: the same{PAGENO}/{nb}/{DATE …}placeholders are translated to Chrome's own template syntax, so one syntax works on both engines.
This engine has no network access, by design. Your JavaScript runs inside a real browser, so an engine that could fetch would be able to read internal services and paint the response into the PDF you get back. Chrome is therefore launched with all outbound requests failing, including to localhost. Everything must be in the payload: inline
<style>, thecssparameter, anddata:URIs for images.remote_assetsandbase_urldo nothing here — an external stylesheet or image simply will not load.urlmode still works, because the gateway fetches that page itself (guarded) and hands Chrome the markup.
Which engine to reach for:
html-to-pdf (mPDF) |
html-to-pdf.browser (Chrome) |
|
|---|---|---|
| Cost | 2 credits | 8 credits |
| Speed | ~0.3–1 s | ~1–2 s |
| Flexbox / grid | no | yes |
| JavaScript | no | yes |
| Remote images / CSS | opt-in via remote_assets |
never — inline or data: only |
| Best for | invoices, statements, reports | dashboards, marketing pages, anything designed for screen |
Endpoints
html-to-pdf— render and return the document.html-to-pdf.info— render exactly the same document and return only the facts about it (pages,bytes,assets_blocked) with no payload. Useful while you iterate on a template and only want to know "does this still fit on one page?" without moving a megabyte of base64 around. It costs the same, because the same rendering work happens.html-to-pdf.image— render, then return one page as a PNG (3 credits).html-to-pdf.browser— render through headless Chrome, exactly as a browser draws it (8 credits).
Limits
htmlup to 2 MB (or 2 MB combined acrossdocuments),cssup to 200 KB, 10 documents per merge.max_pages(default 50, maximum 200) refuses an over-long document rather than returning it — an unbounded template is usually a bug, and you are not charged when it trips.- No JavaScript, no flexbox and no CSS grid on the default engine — it is a print renderer, not a browser. Use
html-to-pdf.browserwhen you need those.
Errors
Every error is JSON, and the ones you can cause by accident are free — invalid_request, payload_too_large, fetch_failed, render_failed, too_many_pages, preview_unavailable and engine_unavailable all return 0 credits charged. You pay for documents you receive.
When to use it
- Invoices, receipts, payslips and contracts generated from a template you already render as HTML.
- Reports and statements that need real page numbers and a running header.
- Statement packs: a cover, a body and standing terms merged into one correctly-paginated file.
- Turning a rendered email or article into an archivable file.
- Thumbnails of any of the above, via
.image, without a PDF viewer in your stack.
Endpoints
Render HTML (or a fetched URL) into a PDF document
The default engine is mPDF — pure PHP, no browser process, so the call is a single synchronous request with nothing to poll and nothing to clean up. It handles the CSS that print layouts actually use: page size and margins, running headers and footers, tables, floats, absolute positioning, web fonts and page breaks.
What it does not do is run JavaScript or lay out flexbox and grid. If your document depends on
either, use .browser instead and pay for the browser process.
A4A3A5LetterLegalTabloidLedger
Defaults to A4
portraitlandscape
Defaults to portrait
document.pdf
false
50
jsonbinary
Defaults to json
attachmentinline
Defaults to attachment
curl -X POST "https://ksty.ch/api/v1/html-to-pdf" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Invoice #1042</h1><p>Thanks for your business.</p>","page_size":"A4","orientation":"portrait","footer_html":"<div style=\"text-align:center;font-size:9pt\">Page {PAGENO} of {nb}</div>","filename":"invoice-1042.pdf","max_pages":50,"output":"json"}'
<?php
$ch = curl_init('https://ksty.ch/api/v1/html-to-pdf');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{ "html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>", "page_size": "A4", "orientation": "portrait", "footer_html": "<div style=\"text-align:center;font-size:9pt\">Page {PAGENO} of {nb}</div>", "filename": "invoice-1042.pdf", "max_pages": 50, "output": "json"}',
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
const response = await fetch('https://ksty.ch/api/v1/html-to-pdf', {
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>",
"page_size": "A4",
"orientation": "portrait",
"footer_html": "<div style=\"text-align:center;font-size:9pt\">Page {PAGENO} of {nb}</div>",
"filename": "invoice-1042.pdf",
"max_pages": 50,
"output": "json"
})
});
const data = await response.json();
import requests
response = requests.post(
'https://ksty.ch/api/v1/html-to-pdf',
headers={'X-Api-Key': 'YOUR_API_KEY'},
json={
"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>",
"page_size": "A4",
"orientation": "portrait",
"footer_html": "<div style=\"text-align:center;font-size:9pt\">Page {PAGENO} of {nb}</div>",
"filename": "invoice-1042.pdf",
"max_pages": 50,
"output": "json"
},
)
data = response.json()
{
"filename": "document.pdf",
"content_type": "application/pdf",
"bytes": 12894,
"pages": 1,
"page_size": "A4",
"orientation": "portrait",
"encrypted": false,
"assets_blocked": [],
"pdf_base64": "JVBERi0xLjQK…"
}
Render the document and return only its facts (pages, bytes) — no payload
A4A3A5LetterLegalTabloidLedger
Defaults to A4
portraitlandscape
Defaults to portrait
false
curl -X POST "https://ksty.ch/api/v1/html-to-pdf.info" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Quarterly report</h1><p>Section one.</p>","page_size":"A4","orientation":"portrait"}'
<?php
$ch = curl_init('https://ksty.ch/api/v1/html-to-pdf.info');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{ "html": "<h1>Quarterly report</h1><p>Section one.</p>", "page_size": "A4", "orientation": "portrait"}',
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
const response = await fetch('https://ksty.ch/api/v1/html-to-pdf.info', {
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"html": "<h1>Quarterly report</h1><p>Section one.</p>",
"page_size": "A4",
"orientation": "portrait"
})
});
const data = await response.json();
import requests
response = requests.post(
'https://ksty.ch/api/v1/html-to-pdf.info',
headers={'X-Api-Key': 'YOUR_API_KEY'},
json={
"html": "<h1>Quarterly report</h1><p>Section one.</p>",
"page_size": "A4",
"orientation": "portrait"
},
)
data = response.json()
{
"pages": 3,
"bytes": 48210,
"page_size": "A4",
"orientation": "portrait",
"assets_blocked": []
}
Render the document and return one page as a PNG image
1
900
A4A3A5LetterLegalTabloidLedger
Defaults to A4
portraitlandscape
Defaults to portrait
false
jsonbinary
Defaults to json
curl -X POST "https://ksty.ch/api/v1/html-to-pdf.image" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Invoice #1042</h1><p>Thanks for your business.</p>","page":1,"width":900,"page_size":"A4","orientation":"portrait","output":"json"}'
<?php
$ch = curl_init('https://ksty.ch/api/v1/html-to-pdf.image');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{ "html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>", "page": 1, "width": 900, "page_size": "A4", "orientation": "portrait", "output": "json"}',
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
const response = await fetch('https://ksty.ch/api/v1/html-to-pdf.image', {
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>",
"page": 1,
"width": 900,
"page_size": "A4",
"orientation": "portrait",
"output": "json"
})
});
const data = await response.json();
import requests
response = requests.post(
'https://ksty.ch/api/v1/html-to-pdf.image',
headers={'X-Api-Key': 'YOUR_API_KEY'},
json={
"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>",
"page": 1,
"width": 900,
"page_size": "A4",
"orientation": "portrait",
"output": "json"
},
)
data = response.json()
{
"page": 1,
"pages": 3,
"width": 900,
"height": 1273,
"bytes": 139014,
"content_type": "image/png",
"image_base64": "iVBORw0KGgo…"
}
Render through headless Chrome — the page exactly as a browser draws it
Renders in real headless Chrome, so flexbox, grid, web fonts and JavaScript all work and the output matches what you see when you print the page from a browser. It costs 8 credits rather than 2 because a browser process is started for the call.
No network access, at all. Chrome runs with egress blocked, so every asset must be inline or a
data: URI — a remote <img> or stylesheet simply will not load, and remote_assets does not apply
here. This is deliberate: your JavaScript executes inside that browser, and an engine that could reach
the network would be able to read internal addresses and paint the results into the PDF you receive.
Use the default endpoint when your document is print-CSS only; use this one when it genuinely needs a browser engine.
A4A3A5LetterLegalTabloidLedger
Defaults to A4
portraitlandscape
Defaults to portrait
250
document.pdf
50
jsonbinary
Defaults to json
curl -X POST "https://ksty.ch/api/v1/html-to-pdf.browser" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Invoice #1042</h1><p>Thanks for your business.</p>","page_size":"A4","orientation":"portrait","wait_ms":250,"max_pages":50,"output":"json"}'
<?php
$ch = curl_init('https://ksty.ch/api/v1/html-to-pdf.browser');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: YOUR_API_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{ "html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>", "page_size": "A4", "orientation": "portrait", "wait_ms": 250, "max_pages": 50, "output": "json"}',
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
const response = await fetch('https://ksty.ch/api/v1/html-to-pdf.browser', {
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>",
"page_size": "A4",
"orientation": "portrait",
"wait_ms": 250,
"max_pages": 50,
"output": "json"
})
});
const data = await response.json();
import requests
response = requests.post(
'https://ksty.ch/api/v1/html-to-pdf.browser',
headers={'X-Api-Key': 'YOUR_API_KEY'},
json={
"html": "<h1>Invoice #1042</h1><p>Thanks for your business.</p>",
"page_size": "A4",
"orientation": "portrait",
"wait_ms": 250,
"max_pages": 50,
"output": "json"
},
)
data = response.json()
{
"filename": "document.pdf",
"content_type": "application/pdf",
"bytes": 124738,
"pages": 1,
"page_size": "A4",
"orientation": "portrait",
"engine": "chrome",
"encrypted": false,
"assets_blocked": [],
"pdf_base64": "JVBERi0xLjQK…"
}
Errors
Errors are JSON with a stable error.code — branch on the code, never on
the message. Everything below carries the X-Request-Id of the failed call.
Requests rejected by this API are not charged, and a 502 after a
charge is refunded automatically.
Response headers
Every successful call reports its own cost, so you never have to guess what a request spent or reconcile it later.