Quickstart
Two things go in — an HTML template and the data to fill it. One thing comes out: a signed URL to a finished PDF.
curl -X POST https://emitforge.com/v1/pdf \
-H "Authorization: Bearer $EMITFORGE_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "<h1>Invoice {{number}}</h1>",
"data": { "number": "2026-014" },
"format": "A4"
}'
{
"id": "rnd_9e9f40b63d2e",
"url": "https://…/rnd_9e9f40b63d2e.pdf?token=…",
"bytes": 12152,
"units": 1,
"expires_at": "2026-08-28T16:38:15.211Z"
}
Download the file from url. It is deleted after 24 hours — we hand you a link, not an archive. If you need to keep the document, store it on your side.
Get a key if you do not have one. 100 renders a month, free, no card.
Authentication
Every request carries your key as a bearer token:
Authorization: Bearer ef_live_…
Two things worth knowing before you wire this into anything:
- The key is shown once. We store a SHA-256 hash, not the key itself. If our database ever leaked, your key would not leak with it — and neither can we read it back to you. Lose it and we issue a new one.
- It belongs on your server. Anything you put in client-side code is public, however minified. If you need rendering from a browser, put your own endpoint in front and keep the key behind it.
POST /v1/pdf
Renders an HTML template into a PDF.
Body
| Field | Type | Description | |
|---|---|---|---|
template | string | required | The HTML to render. Up to 2 MB. |
data | object | optional | Values for the placeholders. Defaults to {}. |
format | string | optional | Paper size: A4, Letter, Legal, A3, A5. Default A4. |
margin | string | optional | Applied to all four sides, in CSS units. Default 18mm. |
landscape | boolean | optional | Default false. |
Response
| Field | Description |
|---|---|
id | Identifier for the render. Quote it if you write to us about a specific file. |
url | Signed link to the PDF, valid for 24 hours. |
bytes | Size of the file. |
units | How much this counted against your allowance. Normally 1; 2 for files over 5 MB. |
expires_at | When the link stops working. |
warnings | Only present when something deserved your attention — see below. |
We tell you when a template misbehaves instead of failing silently. If a placeholder has no matching value it renders empty and comes back in warnings.unresolved_placeholders. If the template tried to fetch a blocked address, that comes back too. A blank space where a customer name should be is worse than an error, and you should hear about it on the first render rather than from a client.
Templates
The template is your HTML — your CSS, your fonts, your layout. We render it in real Chromium, so what you see in a browser is what you get in the file. Flexbox, grid, page breaks and web fonts all work.
Placeholders
{{name}} is replaced by data.name, and dotted paths reach into nested objects:
// template
<h1>{{customer.name}}</h1>
<p>Total: {{invoice.total}}</p>
// data
{ "customer": { "name": "Acme Ltd" },
"invoice": { "total": "US$ 89.00" } }
Escaping
Values are HTML-escaped by default. A customer named <script> becomes text, not a script tag — which matters, because the data usually comes from your users.
When you deliberately want to inject markup, use triple braces: {{{body}}}. That is an explicit choice, and it is yours to make: whatever goes in there must already be trusted.
Fonts and images
Embed them. A template that fetches a font from a CDN waits on the network on every single render, and you pay for that wait. Base64 data URIs render fastest and never break because a third party had a bad day.
Limits
| Limit | Value | What happens |
|---|---|---|
| Render time | 10 seconds | Hard stop, 504 render_timeout. Almost always an external resource the template is waiting for. |
| Template size | 2 MB | 413 template_too_large |
| Requests | 10 per second | 429, safe to retry |
| Free tier | 100 renders/month | 429 quota_exceeded, resets on the 1st |
| File retention | 24 hours | Deleted automatically |
A render is one document. Files over 5 MB count as two — the response tells you which, in units, so you never have to guess how your allowance was spent.
Some addresses are blocked at render time: localhost, private ranges and cloud metadata endpoints. A template has no legitimate reason to reach them, and allowing it would turn "render my HTML" into a way to read our internal network.
Errors
Every error has the same shape, so you can handle it in one place:
{ "error": { "code": "render_timeout", "message": "…" } }
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_body | Body is not JSON. |
| 400 | missing_template | No template, or it is empty. |
| 400 | invalid_data | data is not an object. |
| 401 | missing_api_key | No Authorization header. |
| 401 | invalid_api_key | Key unknown or revoked. |
| 405 | method_not_allowed | Use POST. |
| 413 | template_too_large | Template over 2 MB. |
| 429 | quota_exceeded | Monthly allowance spent. The response carries used, quota and resets. |
| 500 | render_failed | The template could not be rendered. Write to us with the id. |
| 503 | store_unavailable | Temporary. Retry. |
| 504 | render_timeout | Passed 10 seconds. |
Retry on 429, 503 and 504. Do not retry 4xx that describe the request itself — the same body will fail the same way.
Examples
There is no SDK to install. It is one POST, and your language already knows how to do it.
const res = await fetch('https://emitforge.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMITFORGE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: '<h1>Invoice {{number}}</h1>',
data: { number: '2026-014' },
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const { url, id } = await res.json();
import os, requests
res = requests.post(
"https://emitforge.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['EMITFORGE_KEY']}"},
json={
"template": "<h1>Invoice {{number}}</h1>",
"data": {"number": "2026-014"},
},
timeout=30,
)
res.raise_for_status()
url = res.json()["url"]
$ch = curl_init('https://emitforge.com/v1/pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EMITFORGE_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'template' => '<h1>Invoice {{number}}</h1>',
'data' => ['number' => '2026-014'],
]),
]);
$out = json_decode(curl_exec($ch), true);
Without writing code
The API is a plain HTTP POST, so it already works inside the automation tools you use. There is nothing to install.
n8n
- Add an HTTP Request node, method POST, URL
https://emitforge.com/v1/pdf - Authentication: Generic → Header Auth, name
Authorization, valueBearer YOUR_KEY - Body: JSON, with
templateanddata - The response carries
url, which you can pass straight into an email or storage node
Zapier and Make
Use Webhooks by Zapier or the HTTP module, same request. A native Zapier integration is in review on their side.
Roadmap
Listed so you know where this is going — not so you can build against it. Only /v1/pdf exists today.
| Endpoint | Status | What it will do |
|---|---|---|
/v1/pdf | Available | HTML template plus data → PDF |
/v1/image | Building | Same engine, same templates → PNG or JPEG at exact dimensions |
/v1/convert | Planned | DOCX, XLSX and PPTX → PDF |
Something missing that would make this useful to you? Write to contato@fluxium.pro. The roadmap is short enough that one good reason moves it.