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

FieldTypeDescription
templatestringrequiredThe HTML to render. Up to 2 MB.
dataobjectoptionalValues for the placeholders. Defaults to {}.
formatstringoptionalPaper size: A4, Letter, Legal, A3, A5. Default A4.
marginstringoptionalApplied to all four sides, in CSS units. Default 18mm.
landscapebooleanoptionalDefault false.

Response

FieldDescription
idIdentifier for the render. Quote it if you write to us about a specific file.
urlSigned link to the PDF, valid for 24 hours.
bytesSize of the file.
unitsHow much this counted against your allowance. Normally 1; 2 for files over 5 MB.
expires_atWhen the link stops working.
warningsOnly 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

LimitValueWhat happens
Render time10 secondsHard stop, 504 render_timeout. Almost always an external resource the template is waiting for.
Template size2 MB413 template_too_large
Requests10 per second429, safe to retry
Free tier100 renders/month429 quota_exceeded, resets on the 1st
File retention24 hoursDeleted 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": "…" } }
HTTPCodeMeaning
400invalid_bodyBody is not JSON.
400missing_templateNo template, or it is empty.
400invalid_datadata is not an object.
401missing_api_keyNo Authorization header.
401invalid_api_keyKey unknown or revoked.
405method_not_allowedUse POST.
413template_too_largeTemplate over 2 MB.
429quota_exceededMonthly allowance spent. The response carries used, quota and resets.
500render_failedThe template could not be rendered. Write to us with the id.
503store_unavailableTemporary. Retry.
504render_timeoutPassed 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: GenericHeader Auth, name Authorization, value Bearer YOUR_KEY
  • Body: JSON, with template and data
  • 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.

EndpointStatusWhat it will do
/v1/pdfAvailableHTML template plus data → PDF
/v1/imageBuildingSame engine, same templates → PNG or JPEG at exact dimensions
/v1/convertPlannedDOCX, 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.