Copy-paste examples for the HTML to PDF API in cURL, JavaScript, PHP and Python. Each one reads page.html, writes page.pdf, and fails loudly when the API answers with an error and not a file. I ran all of them before putting them here.
They all send layout=document, which renders the HTML the way you wrote it, over as many pages as it needs. Without it you get the older single_page default: one A4 page, everything in Arial, and whatever doesn't fit cut off. The overview guide has the rendering rules.
This is the page.html they use:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { size: A4; margin: 20mm; }
body { margin: 0; font-family: Georgia, serif; }
h1 { color: #0b4f8a; border-bottom: 2px solid #0b4f8a; padding-bottom: 8px; }
p { line-height: 1.6; }
</style>
</head>
<body>
<h1>Monthly report</h1>
<p>Revenue is up 12% on last month. Café sales: 1 240 €.</p>
</body>
</html>
cURL
The trick with curl is getting a whole HTML file into one parameter without fighting the shell over quotes. --data-urlencode with name@file does it:
curl -X POST "https://apixies.io/api/v1/html-to-pdf" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "html@page.html" \
-d "layout=document" \
--max-time 60 \
-o page.pdf
That reads page.html, URL-encodes it and sends it as the html field, with layout as a second form field. No escaping by hand.
If you'd rather send JSON, let jq build the body. -Rs reads the file as one raw string:
jq -Rs '{html: ., layout: "document"}' page.html | curl -X POST "https://apixies.io/api/v1/html-to-pdf" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
-o page.pdf
Don't build that JSON with echo and string concatenation. The first double quote in your HTML breaks it.
Catch errors
Here's the trap. -o page.pdf saves whatever comes back. With a wrong key, this is what ends up in your "PDF":
{"status":"error","http_code":401,"code":"INVALID_API_KEY","message":"Invalid API key.","errors":{"X-API-Key":["The API key is invalid or has been revoked."]}}
curl exits with 0, because the transfer worked. So check the status yourself:
#!/bin/bash
# topdf.sh page.html page.pdf
STATUS=$(curl -s -X POST "https://apixies.io/api/v1/html-to-pdf" \
-H "X-API-Key: $APIXIES_API_KEY" \
--data-urlencode "html@$1" \
-d "layout=document" \
--max-time 60 \
-o "$2" -w "%{http_code}")
if [ "$STATUS" != "200" ]; then
echo "Failed with HTTP $STATUS:" >&2
cat "$2" >&2; echo >&2
rm -f "$2"
exit 1
fi
Now a failure prints the JSON, deletes the fake PDF and returns exit code 1, which is what cron and CI need.
--max-time 60 matters too. Rendering starts a real browser, so allow a few seconds. Without a limit, a stuck connection hangs your script forever.
JavaScript
Node 18 or newer, no packages:
import { readFile, writeFile } from "node:fs/promises";
async function htmlToPdf(html) {
const res = await fetch("https://apixies.io/api/v1/html-to-pdf", {
method: "POST",
headers: {
"X-API-Key": process.env.APIXIES_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, layout: "document" }),
signal: AbortSignal.timeout(60_000),
});
if (!res.headers.get("content-type")?.startsWith("application/pdf")) {
const body = await res.json();
throw new Error(`${body.code}: ${body.message}`);
}
return Buffer.from(await res.arrayBuffer());
}
await writeFile("page.pdf", await htmlToPdf(await readFile("page.html", "utf8")));
Use arrayBuffer(), not text(). Reading a PDF as text mangles the bytes and you get a file that won't open.
To hand the PDF to a browser as a download, send the buffer with the right headers. With plain node:http:
import http from "node:http";
http.createServer(async (req, res) => {
try {
const pdf = await htmlToPdf(renderReport(req.url));
res.writeHead(200, {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="report.pdf"',
});
res.end(pdf);
} catch (error) {
res.writeHead(502, { "Content-Type": "text/plain" });
res.end("Couldn't build the PDF. Try again in a moment.");
}
}).listen(3000);
renderReport() is yours. It returns the HTML string. In Express the body of the handler is the same, with res.set() and res.send(pdf).
Keep the call on your server. From browser JavaScript you'd have to ship your API key to every visitor.
PHP
function htmlToPdf(string $html): string
{
$ch = curl_init('https://apixies.io/api/v1/html-to-pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['html' => $html, 'layout' => 'document']),
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('APIXIES_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($body === false) {
throw new RuntimeException('Request failed');
}
if (! str_starts_with($type, 'application/pdf')) {
$error = json_decode($body, true);
throw new RuntimeException(($error['code'] ?? 'ERROR') . ': ' . ($error['message'] ?? 'Unknown error'));
}
return $body;
}
file_put_contents('page.pdf', htmlToPdf(file_get_contents('page.html')));
In Laravel the HTTP client makes it shorter, and a Blade view gives you the HTML:
$response = Http::withHeaders(['X-API-Key' => config('services.apixies.key')])
->timeout(60)
->post('https://apixies.io/api/v1/html-to-pdf', [
'html' => view('pdf.report', $data)->render(),
'layout' => 'document',
]);
abort_unless(str_starts_with((string) $response->header('Content-Type'), 'application/pdf'), 502);
return response($response->body(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="report.pdf"',
]);
Python
import os
import requests
def html_to_pdf(html):
res = requests.post(
"https://apixies.io/api/v1/html-to-pdf",
json={"html": html, "layout": "document"},
headers={"X-API-Key": os.environ["APIXIES_API_KEY"]},
timeout=60,
)
if not res.headers.get("Content-Type", "").startswith("application/pdf"):
body = res.json()
raise RuntimeError(f"{body['code']}: {body['message']}")
return res.content
with open("page.html", encoding="utf-8") as f:
pdf = html_to_pdf(f.read())
with open("page.pdf", "wb") as f:
f.write(pdf)
res.content is bytes, res.text isn't. And open the output file with "wb".
Errors you'll meet
All four examples turn these into an exception or a non-zero exit:
| Status | Code | What to do |
|---|---|---|
| 401 | MISSING_AUTH, INVALID_API_KEY |
Check the X-API-Key header |
| 422 | VALIDATION_FAILED |
html is missing or bigger than 512 KB, or layout has a value it doesn't know |
| 429 | DAILY_QUOTA_EXCEEDED |
The free tier is 75 requests a day. Wait for the reset, and store PDFs you've already made |
| 503 | PDF_GENERATION_FAILED |
The renderer didn't start or timed out. Retry once after a short pause |
A typo in the layout is one of those 422s, and the errors object tells you which field it was:
{"status":"error","http_code":422,"code":"VALIDATION_FAILED","message":"Validation failed.","errors":{"layout":["The selected layout is invalid."]}}
One thing isn't an error and probably should be. Leave layout out completely and the request succeeds in single_page mode, where content longer than one A4 page is cut off and you still get a 200. If a PDF comes back short, check that the field made it into the request.
Next steps
- Convert HTML to PDF with a REST API: what renders, what doesn't, and why
- Generate PDF invoices: a full template with data filled in
- HTML to PDF API reference
- HTML to PDF tool: a quick try in the browser, no key needed
- All guides