Emberkit PDF
A JSON-in, PDF-out HTTP API. Every endpoint lives under https://pdf.emberkit.dev/v1 and speaks JSON. Renders happen in headless Chromium, so anything that prints correctly from your browser prints correctly here.
Quickstart
The smallest useful request is a single html field. The response body is the PDF itself.
curl -X POST https://pdf.emberkit.dev/v1/render \
-H 'Authorization: Bearer ek_live_...' \
-H 'Content-Type: application/json' \
-o hello.pdf \
-d '{ "html": "<h1>Hello, world</h1>" }'Node.js
const res = await fetch("https://pdf.emberkit.dev/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EMBERKIT_PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
template: "<h1>Invoice {{invoice.number}}</h1><p>{{currency invoice.total currency='CAD'}}</p>",
data: { invoice: { number: "INV-0184", total: 9200 } },
options: { format: "A4", margin: { top: "20mm", bottom: "20mm" } },
filename: "invoice.pdf",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const pdf = Buffer.from(await res.arrayBuffer());Prefer to try before wiring anything up? The playground runs the same engine with no key at all.
Authentication
Send your key as a bearer token. X-Api-Key is accepted as an alternative for clients that cannot set an Authorization header.
Authorization: Bearer ek_live_xxxxxxxxxxxxxxxxxxxxxxxxKeys are stored only as a SHA-256 hash — we cannot show you a key again after it is issued, so keep it somewhere safe. Never ship one in client-side code: anyone who can read your bundle can spend your quota. Call the API from your server.
POST /v1/render
Turns HTML — literal, or a Handlebars template plus data — into a PDF. Supply exactly one of html, template or templateId.
{
"template": "<h1>{{title}}</h1>",
"data": { "title": "Q3 Report" },
"options": {
"format": "A4",
"landscape": false,
"margin": { "top": "20mm", "right": "16mm", "bottom": "20mm", "left": "16mm" },
"printBackground": true,
"scale": 1,
"waitUntil": "networkidle0",
"timeoutMs": 20000,
"tagged": true
},
"metadata": { "title": "Q3 Report", "author": "Northwind Analytics" },
"output": "binary",
"filename": "q3-report.pdf"
}| Field | Type | Description |
|---|---|---|
html | string | Literal HTML. No templating applied. |
template | string | Handlebars source, compiled against `data`. |
templateId | string | One of the built-in starters — invoice, report, receipt, certificate. If data is omitted the sample data is used. |
data | object | Values available to the template. Defaults to {}. |
baseUrl | string | Absolute URL used to resolve relative asset paths in your HTML. |
options | object | Page setup — see Render options. |
metadata | object | title, author, subject, keywords, creator — written into the PDF. |
output | string | binary (default) returns application/pdf. base64 or json return a JSON envelope. |
filename | string | Sets the Content-Disposition filename. |
JSON response
With "output": "base64" you get metadata alongside the document — useful when you want the page count or the render warnings without parsing the PDF yourself.
{
"id": "req_8f2c14ab90de4711b6a3c052",
"object": "pdf",
"filename": "q3-report.pdf",
"pages": 3,
"bytes": 148213,
"durationMs": 940,
"pdf": "JVBERi0xLjQKJeLjz9M...",
"encoding": "base64",
"quota": { "remaining": 19964, "resetsAt": "2026-09-01T00:00:00.000Z" }
}Response headers
Binary responses carry the same information in headers: X-Emberkit-Request-Id, X-Emberkit-Pages, X-Emberkit-Bytes, X-Emberkit-Duration-Ms, RateLimit-Remaining and X-Emberkit-Quota-Remaining.
POST /v1/render/url
Captures a live page. Useful when the document already exists as a route in your app and you would rather not duplicate it as a template.
curl -X POST https://pdf.emberkit.dev/v1/render/url \
-H 'Authorization: Bearer ek_live_...' \
-H 'Content-Type: application/json' \
-o page.pdf \
-d '{
"url": "https://example.com/reports/482",
"options": { "format": "A4", "waitForSelector": "#report-ready" },
"extraHeaders": { "X-Report-Token": "..." }
}'Only public http and https URLs can be rendered. Hostnames that resolve to private, loopback, link-local or cloud-metadata addresses are rejected with url_not_allowed, and every redirect in the chain is re-checked against the same rules.
Pages that finish loading asynchronously should signal readiness — set waitForSelector to an element your app renders once the data has arrived. It is far more reliable than guessing with delayMs.
Render options
| Option | Default | Description |
|---|---|---|
format | A4 | Letter, Legal, Tabloid, Ledger, A0–A6. |
width / height | — | Explicit page size, e.g. 80mm × 200mm. Overrides format; must be supplied together. |
landscape | false | Rotate the page. |
margin | 20/16/20/16 mm | Per-side CSS lengths (px, mm, cm, in, pt, pc). |
printBackground | true | Include background colours and images. Turn off for ink-friendly output. |
scale | 1 | 0.1 – 2. Scales rendered content. |
pageRanges | — | Emit a subset, e.g. "1-3" or "1,4,7-9". |
waitUntil | networkidle0 | load, domcontentloaded, networkidle0, networkidle2. |
waitForSelector | — | Block until this selector appears. The reliable readiness signal. |
delayMs | 0 | Extra settle time after load, max 5000. A last resort. |
timeoutMs | 20000 | 1000 – 60000. Exceeded renders return 504. |
media | print | Which CSS media type to emulate. Use screen when your print stylesheet hides things you want. |
preferCSSPageSize | false | Let the document’s own @page rule win. |
fonts | auto | Which bundled font families to embed — auto, all or none. See Typography. |
tagged | true | Emit a tagged, more accessible PDF. |
outline | false | Build bookmarks from headings. |
omitBackground | false | Transparent page background, for overlays. |
Fonts and images are awaited automatically before capture, so a webfont will not silently fall back to Times New Roman.
Headers & footers
Chrome draws running blocks inside the page margin. Give the block a height and we reserve that margin for you — the single most common reason a footer renders as an invisible sliver.
{
"options": {
"margin": { "top": "0mm", "bottom": "0mm" },
"header": {
"height": "16mm",
"html": "<div style='width:100%;padding:0 16mm;font-size:8pt'>Acme Inc</div>"
},
"footer": {
"height": "14mm",
"html": "<div style='width:100%;padding:0 16mm;text-align:right;font-size:8pt'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"
}
}
}Inside a running block, Chrome substitutes the classes pageNumber, totalPages, date, title and url. Styles must be inline and font sizes explicit — running blocks do not inherit your document CSS.
Typography
A document must look the same wherever it renders. Serverless Chromium ships almost no fonts, so a template asking for Helvetica would get one face on your laptop and something else in production — and you would not find out until a customer noticed.
So we embed the fonts in the document itself. Three variable families ship with the API and are compiled into every render, which means output depends on your template, never on the machine that drew it.
| Family | Also answers to | Typeface |
|---|---|---|
Emberkit Sans | Inter, Helvetica, Arial, Segoe UI, system-ui, Roboto | Inter |
Emberkit Serif | Georgia, Times, Times New Roman, Garamond | Source Serif 4 |
Emberkit Mono | Menlo, Consolas, Monaco, SF Mono, Courier New, ui-monospace | JetBrains Mono |
The second column is the useful part: an existing stack like font-family: Helvetica, Arial, sans-serif resolves to Emberkit Sans automatically. You do not have to rewrite your CSS to get deterministic output — but naming the family directly is clearer, and all weights from 100 to 900 are available since these are variable fonts.
Controlling what gets embedded
| Value | Behaviour |
|---|---|
auto | Default. Embeds the sans family plus any other family your document actually mentions. |
all | Embeds all three families. Use when font-family is set at runtime by script, where we cannot see it in the markup. |
none | Embeds nothing. Only sensible when your template loads its own webfonts. |
Loading your own webfont still works and still wins — our faces are injected before your CSS, so anything you declare overrides them. That is the right way to put a brand typeface on a document.
Symbols
Text coverage is Latin and Latin Extended. On top of that, arrows, geometric shapes, dingbats and check marks — → ▲ ● ✓ ★ ⚠ and friends — are carried by fallback faces that are attached automatically, per glyph, only when your document contains one. A KPI card showing ▲ 12.4% renders the same everywhere without you naming a second font.
Greek, Cyrillic, CJK and emoji are not bundled. Load a webfont in the template if you need them and it will be used.
Templating
Templates are Handlebars. Beyond the standard #if, #each, #unless and #with blocks, these helpers ship with the API:
{{currency invoice.total currency="CAD" locale="en-CA"}} → CA$9,200.00
{{date invoice.due format="long" tz="America/Halifax"}} → 31 August 2026
{{number stats.seats decimals=0}} → 1,240
{{percent growth decimals=1}} → 12.4%
{{currency (multiply item.quantity item.rate)}} → $6,000.00
{{currency (sum items "amount")}} → $9,200.00
{{#each items}}
{{inc @index}}. {{this.name}} — {{currency this.rate currency=../currency}}
{{/each}}
{{#if (gte invoice.total 5000)}}<span>Wire transfer required</span>{{/if}}Values are HTML-escaped by default. Use triple braces — {{{value}}} — only for markup you generated yourself and trust.
GET /v1/templates
Lists the built-in starters, their sample data and the full helper vocabulary. Unauthenticated, so you can explore before signing up. Add ?include=source to get the template markup too.
curl https://pdf.emberkit.dev/v1/templates?include=sourceGET /v1/usage
Reports what the calling key has spent this cycle and what remains.
curl https://pdf.emberkit.dev/v1/usage \
-H 'Authorization: Bearer ek_live_...'Errors
Every failure returns the same envelope. The requestId is the only thing we need to find a render in our logs, so include it if you write in.
{
"error": {
"type": "invalid_request",
"code": "template_syntax_error",
"message": "Parse error on line 4: Expecting 'CLOSE', got 'EOF'",
"param": "template",
"requestId": "req_8f2c14ab90de4711b6a3c052"
}
}| Status | Type | When |
|---|---|---|
| 400 | invalid_request | Malformed JSON, failed validation, template syntax error, blocked URL. |
| 401 | authentication_error | Missing, unknown or revoked key. |
| 403 | permission_error | Monthly quota exhausted. |
| 404 | not_found | Unknown templateId. |
| 413 | invalid_request | Request body over 4 MB. |
| 422 | render_error | Chromium could not produce a document. |
| 429 | rate_limit_error | Rate limited. Honour Retry-After. |
| 504 | timeout_error | Render exceeded timeoutMs. |
| 500 | api_error | Our fault. Retry, then tell us. |
A failed render is never billed — the quota reservation is returned automatically. Retry 429 and 5xx responses with exponential backoff; do not retry 4xx, since the request will fail identically.
Limits are counted centrally, so they hold no matter which of our containers serves you. GET /v1/usage reports "durable": true when that is the case; if our counter store is ever unreachable we keep serving requests rather than failing them, and say so with "durable": false plus a degraded note.
Limits
| Limit | Value |
|---|---|
| Request body | 4 MB |
| Template source | 512 KB |
| Render timeout | 60 s maximum, 20 s default |
| Rate limit | Per plan — 10 to 600 requests / minute |
| Monthly renders | Per plan — 100 to 150,000 |
Need more than Scale, a dedicated region, or an on-premise deploy? Email us.