API Reference
Base URL: https://api.pdfkraft.com/api/v1
Authentication
All API requests must include your API key in the Authorization header. Your key is shown once at registration and can be regenerated from the Account page.
Authorization: Bearer YOUR_API_KEY
Besides the account's primary key, you can create scoped keys with limited permissions — recommended for integrations.
API keys & scopes
Scoped keys are created on the API Keys page (or via the endpoints below using a dashboard session) and can be limited two ways:
| Restriction | Effect |
|---|---|
scopes: ["generate"] | Can generate documents and read templates, but any template write (create, update, publish, delete) returns 403 insufficient_scope. ["full"] keys behave like the primary key. |
template_ids / folder_ids | Non-empty lists restrict generation to those templates (or templates in those folders). Restricted keys also cannot use raw_html. Empty lists (default) allow all templates. |
Key management
| Method & path | Description |
|---|---|
| GET /api_keys/current | Introspect the calling key: its scopes and restrictions, plus your plan and quota. Works with any API key — use it to discover what a pasted key may do. |
| GET /api_keys | List scoped keys. Dashboard session only. |
| POST /api_keys | Create a scoped key ({name, scopes, template_ids, folder_ids}). Returns the full key once. Dashboard session only — a leaked API key cannot mint further keys. |
| DELETE /api_keys/:id | Revoke a key. Takes effect immediately. Dashboard session only. |
GET /api_keys/current — response 200
{
"api_key": {
"id": "key-uuid", // null for the account's primary key
"name": "n8n production", // null for the primary key
"prefix": "89c32ffb",
"scopes": ["generate"],
"template_ids": [],
"folder_ids": []
},
"plan": "starter",
"effective_plan": "starter",
"on_trial": false,
"quota": {
"limit": 500,
"used": 123,
"remaining": 377,
"resets_at": "2024-02-01T00:00:00.000Z"
}
}Rate limits
| Limit | Value |
|---|---|
| Global (all authenticated routes) | 60 requests / minute per API key |
Document generation (POST /documents, /documents/sync, /documents/preview) — Free | 10 requests / minute per API key |
| Document generation — Starter | 30 requests / minute per API key |
| Document generation — Pro | 120 requests / minute per API key |
| Login | 10 requests / minute per IP |
| Register · Forgot password | 5 requests / minute per IP |
| Reset password | 10 requests / minute per IP |
When rate-limited the API returns 429 with code: "rate_limited" and a Retry-After header indicating seconds to wait. Authenticated limits are keyed by API-key; auth-endpoint limits are keyed by client IP.
Every document-generation response also carries the current window state, so integrations can throttle before hitting a 429:
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 X-RateLimit-Reset: 1783353144 // Unix epoch seconds
Doing a large batch run (e.g. a month-end invoice run)? Pace requests to stay under your tier rather than bursting — there is currently no separate batch endpoint.
Idempotency
Generation requests (POST /documents, POST /documents/sync) accept an optional Idempotency-Key header. Retrying with the same key within 24 hours returns the original document instead of generating (and billing) again — safe for network timeouts and at-least-once job queues.
curl -X POST https://api.pdfkraft.com/api/v1/documents/sync \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-8412-invoice" \
-H "Content-Type: application/json" \
-d '{ "document_template_id": "...", "payload": { ... } }'- Replayed responses include the header
Idempotency-Replayed: true. - If the first request with a key is still in flight, a concurrent retry gets
409 idempotency_key_in_flight— retry after a moment. - Keys are scoped to your account and expire after 24 hours.
Documents
/documents— async generationEnqueue a document for generation. Returns immediately with a document id and status: "pending". Poll GET /document_cards/:id or use webhooks to know when it's ready.
Request body
{
// One of these is required:
"document_template_id": "uuid",
"raw_html": "<html>...</html>",
// Optional:
"payload": { "key": "value" },
"meta": { "_webhook_channel": "orders" },
"filename": "invoice_{{number}}.pdf",
"password": "secret",
"output_type": "pdf" | "png" | "jpg" | "webp",
"reproducible": false,
"strict_variables": false,
"ttl": 3600,
"share": true,
"template_version": 3
}Response 201
{
"id": "doc-uuid",
"status": "pending",
"created_at": "2024-01-01T00:00:00.000Z",
"unresolved_variables": []
}Fields
| Field | Type | Description |
|---|---|---|
| document_template_id | string (UUID) | ID of a published template. Mutually exclusive with raw_html. |
| raw_html | string | Raw HTML to render directly. Mutually exclusive with document_template_id. When a non-empty payload is supplied, the HTML is rendered as a Liquid template with those variables (snippets included); without a payload it renders as-is and {{ }} passes through literally. External assets (images, fonts, stylesheets) must be reachable at public URLs — requests to loopback, link-local, or private (RFC 1918) addresses are blocked by the renderer. See Images & assets. |
| payload | object | Variables merged into the Liquid template. Default: {}. |
| meta | object | Arbitrary metadata stored with the document. Use _webhook_channel to route webhook events. |
| filename | string | Output filename. Supports {{variable}} placeholders replaced from payload. Sanitized to a single safe name — path separators and control characters are stripped. Default: auto-generated. |
| password | string | Password-protect the PDF. Only applies to PDF output. If omitted, the template's default password (if set) applies. Write-only — it is never returned in document cards, webhook payloads, or the meta object. |
| output_type | enum | pdf, png, jpg, or webp. Overridden by the template's output type when using a template. |
| reproducible | boolean | PDF output only. Normalizes generation metadata (timestamps, document ID, title) so identical requests produce byte-identical files — useful for caching, diffing, and snapshot tests. Guaranteed on unchanged infrastructure only (renderer upgrades may alter output) and not compatible with password. Default false. |
| strict_variables | boolean | When true, reject the request with 422 unresolved_variables instead of generating if the payload leaves any template variable unresolved. See Liquid templating. Default false — generation proceeds either way, and the resolved/unresolved list is always reported (see below). |
| ttl | integer (seconds) | Per-request retention override. Shortens (never extends) the plan's retention window and any template-level ttl — use it to make a specific document expire quickly for PII hygiene. |
| share | boolean | Pro plan only. Set to false to omit public_share_link entirely — no unauthenticated share URL is created for this document. Default true. |
| template_version | integer | Pin generation to a specific published snapshot from GET /document_templates/:id/versions, so a publish that happens after you enqueue this request can't change what it renders. 404 template_version_not_found if the version doesn't exist. See Templates. |
Every generation response — async, sync, and preview — reports unresolved_variables: the dotted payload paths the template reads that this payload leaves genuinely blank (a missing key, or a missing array the template loops over). An explicit null or "" doesn't count — that's a value you supplied on purpose, usually for a field the template already guards with {% if %}. On the async route it's in the immediate response body; on sync/preview it's in the returned card's meta.unresolved_variables.
/documents/sync— synchronous generationSame request body as POST /documents. Blocks until generation completes and returns the full document card. Timeout is 30 s on Free/Starter and 120 s on Pro. Returns 201 on success or 500 on generation failure — the failure response is still a document card, with status: "failure", a human-readable failure_cause, and a machine-readable failure_code (mirrored as code).
Add "dry_run": true to render exactly like /documents/preview instead — no quota reservation, no webhooks, no public share link, and the file expires after 1 hour. Useful for a one-line "test this request without committing to it" toggle without changing endpoints. Ignores Idempotency-Key — a dry run is never a real document, so there is nothing to dedupe.
Example request
curl -X POST https://api.pdfkraft.com/api/v1/documents/sync \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"document_template_id": "your-template-id",
"payload": { "name": "Alice", "total": 99.00 },
"filename": "invoice_{{name}}.pdf"
}'Response 201
{
"id": "doc-uuid",
"status": "success",
"filename": "invoice_Alice.pdf",
"download_url": "https://storage.pdfkraft.com/...",
"public_share_link": null,
"output_type": "pdf",
"document_template_identifier": "my-invoice",
"failure_cause": null,
"failure_code": null,
"meta": {},
"created_at": "2024-01-01T00:00:00.000Z",
"generated_at": "2024-01-01T00:00:01.234Z"
}/documents/preview— dry-run render (does not count quota)Renders through the exact production pipeline but never reserves quota, fires webhooks, or creates share links; the file expires after 1 hour. Use it to test payloads during integration development. Same body as POST /documents, plus use_draft: true to render a template's current draft instead of its published version, and strict_variables to get a 422 instead of a blank field for anything the payload leaves unresolved. Counts toward your plan's generation rate limit — see Rate limits.
curl -X POST https://api.pdfkraft.com/api/v1/documents/preview \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"document_template_id": "your-template-id",
"use_draft": true,
"payload": { "name": "Alice" }
}'/document_cards— list documentsReturns a paginated list of document cards. 24 per page.
| Query param | Type | Description |
|---|---|---|
| page | integer | Page number, default 1. |
| status | string | Filter: pending, generating, success, failure. |
| template_id | string (UUID) | Filter by template. |
{
"data": [ /* document card objects */ ],
"total": 42,
"page": 1,
"pages": 2
}/document_cards/:id— get a documentReturns a single document card with a fresh presigned download_url (1-hour validity). Poll this endpoint after async generation to check for completion.
POST /documents, poll GET /document_cards/:id every second until status is success or failure. Alternatively, use POST /documents/sync to block until done./documents/:id/download— download fileGenerates a fresh presigned URL and redirects (302) to the file in object storage. Returns 400 if the document has not yet been generated.
Document card object
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Document ID. |
| status | enum | pending → generating → success or failure. |
| filename | string | null | Final filename. Null until generation completes. |
| download_url | string | null | Presigned download URL, valid for 1 hour. Null until status is success. |
| public_share_link | string | null | Permanent public URL (Pro plan only). Null on Free/Starter. |
| output_type | enum | pdf, png, jpg, or webp. |
| document_template_identifier | string | null | Identifier slug of the template used, or null for raw HTML. |
| failure_cause | string | null | A user-safe, human-readable reason generation failed — a category such as a rendering, template, stylesheet, or storage error. Non-null only when status is failure. |
| failure_code | string | null | Stable machine-readable code for the failure — branch on this instead of string-matching failure_cause. See Errors for the full list. |
| meta | object | Metadata stored at creation time, plus unresolved_variables (see above). password is never included even if you set one. |
| created_at | ISO 8601 | When the document was submitted. |
| generated_at | ISO 8601 | null | When generation completed. Null until status is success. |
Templates
Templates are HTML + SCSS documents with Liquid syntax for dynamic data. Create and edit them in the dashboard or entirely via the API. Every template has a draft and a published version — writes always target the draft; publishing copies the draft live. A template must be published before it can be used for generation. Template writes require a full-scope key.
/document_templates— create (and optionally publish)Creates a template. body, scss_style, sample_data, and settings write the draft (the dashboard-style *_draft names are also accepted). Pass publish: true to publish in the same request, so one call yields a render-ready template. Returns 201 with the full template, or 409 identifier_taken.
curl -X POST https://api.pdfkraft.com/api/v1/document_templates \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoice",
"identifier": "my-invoice",
"edition_mode": "code",
"output_type": "pdf",
"body": "<h1>Invoice {{ number }}</h1>",
"scss_style": "h1 { color: #223; }",
"sample_data": { "number": "INV-001" },
"settings": { "page_format": "A4" },
"publish": true
}'/document_templates/:id— update the draft (PUT is identical)Partial update: only fields present in the request change. Accepts the same field aliases as create. settings merges over the current draft settings, while settings_draft replaces them wholesale. Add publish: true to publish the updated draft in the same request. Draft edits never affect the published version until you publish.
/document_templates/:id/publishCopies the draft to the published version and sets published_at. Returns 400 empty_template if the draft body is empty. Every publish (from this route, or publish: true on PUT/PATCH) appends a new entry to the template's version history below — nothing is ever overwritten.
/document_templates/:id/versions— publish historyLists every published snapshot, newest first. Pin POST /documents/ /documents/sync to one with template_version so a publish that lands mid-flight can't change documents you already enqueued.
// GET /document_templates/:id/versions
{ "data": [
{ "version": 4, "published_at": "2024-01-03T00:00:00.000Z" },
{ "version": 3, "published_at": "2024-01-02T00:00:00.000Z" },
{ "version": 2, "published_at": "2024-01-01T12:00:00.000Z" },
{ "version": 1, "published_at": "2024-01-01T00:00:00.000Z" }
] }/document_templates/:id/versions/:version— snapshot contentFull content of one published snapshot: body, scss_style, settings, sample_data, ttl, use_forms, and password exactly as they were at that publish. Returns 404 if that version doesn't exist.
/document_templates/:id/versions/:version/rollbackRestores that snapshot as the live published and draft state (so a later accidental publish can't undo the rollback), and records the restore itself as a new version — history is append-only, so a rollback to version 3 shows up as a new version 5 with version 3's content, not as a deletion of versions 4 and 5. Returns the updated template plus { rolled_back_to, new_version }.
/document_templates/:id/variables— payload introspectionStatically analyzes the template and returns the payload variables it reads — validate your payload contract at integration time instead of shipping blank PDFs. Loop variables are reported against their collection (items[].qty). Analyzes the published body by default; use ?source=draft for the draft. Best-effort: variables used inside snippets are not included.
// GET /document_templates/:id/variables
{
"source": "published",
"variables": [
"customer.name",
"order.items[].qty",
"order.items[].sku",
"order.number"
],
"loops": ["order.items"]
}/document_templates/:id/previewRenders the current draft with its sample data through the production pipeline. No quota, no webhooks; the file expires after 1 hour. Returns {document_id, download_url, generation_logs}. (To preview with a custom payload, use POST /documents/preview with use_draft: true.)
/document_templates/:idDeletes the template. Existing documents are kept (their template_id becomes null). Returns 204.
/document_template_cardsPaginated list of templates. Query params: page, folder_id, search.
{
"data": [
{
"id": "uuid",
"name": "Invoice",
"identifier": "my-invoice",
"output_type": "pdf",
"published_at": "2024-01-01T00:00:00.000Z",
"created_at": "...",
"updated_at": "..."
}
],
"total": 3,
"page": 1,
"pages": 1
}/document_templates/:idFull template including draft body, SCSS, settings, and sample data.
Settings object
The settings object controls page layout. All fields are optional; on PATCH it merges over the current draft settings.
| Field | Type | Description |
|---|---|---|
| page_format | enum | A4, Letter, or custom. |
| custom_width_mm / custom_height_mm | number | Page size in millimetres when page_format is custom. |
| orientation | enum | portrait (default) or landscape. |
| margin_top_mm / margin_bottom_mm / margin_left_mm / margin_right_mm | number | Page margins in millimetres, 0–100. Default 10. |
| header_html / footer_html | string | HTML fragments repeated at the top/bottom of every PDF page. Rendered in an isolated context — inline your styles, and embed any images as data URIs. Custom @font-face fonts are not supported here (a Chromium limitation on native print headers/footers); the main document body has no such restriction. |
| image_width / image_height | number | Viewport size in pixels for image output types (png, jpg, webp). |
| wait_for_js | boolean | When the document contains a <script>, rendering waits an extra 2 seconds after page load so client-side JavaScript (charts, QR codes, async data) can finish. Script-free documents are never delayed. Default true; set false to render immediately on load. |
| emoji_rendering | boolean | Currently disabled regardless of value: it forced color-emoji presentation for dual-presentation characters (❤ ✈ © …), but ASCII digits and #/* share that same Unicode classification (they combine with a keycap mark), so it also corrupted digit rendering — every number in the document — via a broken fallback font. If you need guaranteed color emoji for a symbol, include the U+FE0F presentation selector in your payload text (e.g. ❤️ rather than ❤); every browser renders that as color by default, with no risk to digits. |
Template-level fields
Alongside settings, these top-level fields (set on create or update) change how documents are generated from the template:
| Field | Type | Description |
|---|---|---|
| password | string | null | Default password for PDFs generated from this template — applied when the request doesn't send its own password (a request password always wins). Previews are never password-protected. |
| ttl | number | null | Retention override in seconds for documents generated from this template. Can only shorten the plan's retention window, never extend it. Useful for short-lived documents like one-time download links. |
| use_forms | boolean | Enables fillable PDF form fields — see below. |
| folder_id | string | null | Folder assignment — see Folders below. |
Fillable PDF forms (AcroForms)
Set use_forms: true on a template to turn elements carrying a data-acrofield attribute into interactive form fields in the generated PDF. Supported types: TEXT, CHECKBOX, DROPDOWN (with OPTIONS:), and DATE (with optional FORMAT:).
<span data-acrofield="TEXT:customer_name"></span> <span data-acrofield="CHECKBOX:terms_accepted"></span> <span data-acrofield="DROPDOWN:country OPTIONS:DE,FR,NL"></span> <span data-acrofield="DATE:signed_on FORMAT:yyyy-mm-dd"></span>
Folders
Templates can be organized into folders (set folder_id on the template). Folders are referenced by key restrictions (folder_ids) and webhook channels (folder-{folderId}). Folder management (GET/POST /folders, PUT/DELETE /folders/:id) requires a dashboard session — it is not available to API keys.
Webhooks
PDFKraft posts a signed payload to your HTTPS endpoint when document generation completes or fails. Register endpoints in the Webhooks dashboard. Endpoint URLs must be publicly reachable http(s) addresses — URLs that resolve to loopback, link-local, or private (RFC 1918) addresses are rejected with 400 invalid_url.
Endpoint management
| Method & path | Description |
|---|---|
| GET /webhook_endpoints | List all endpoints. |
| POST /webhook_endpoints | Create an endpoint (URL must be public). Returns the signing secret (shown once). |
| PUT /webhook_endpoints/:id | Update URL (re-validated), channels, or enabled state. |
| DELETE /webhook_endpoints/:id | Delete an endpoint. |
| GET /webhook_endpoints/:id/deliveries | Delivery history (100 per page). |
| POST /webhook_endpoints/:id/deliveries/:delivery_id/replay | Re-enqueue a failed delivery. |
Channels
Each endpoint subscribes to one or more channels, matched against documents at delivery time.
| Channel value | Matches |
|---|---|
| * | Every document event. |
| template-{templateId} | Documents from a specific template UUID. |
| folder-{folderId} | Documents from any template inside a specific folder. |
| any string | Documents where meta._webhook_channel equals this string. |
Headers sent to your endpoint
| Header | Value |
|---|---|
| X-PDFKraft-Event | documents.generation.success or documents.generation.failure |
| X-PDFKraft-Timestamp | Unix epoch seconds (string) at delivery time. |
| X-PDFKraft-Signature | HMAC-SHA256(secret, "{timestamp}.{body}") hex string. |
Payload body
The body is JSON: an event string and the full document card (same shape as the API). The documents.generation.failure event fires once, after all generation attempts are exhausted, and carries the reason in failure_cause / failure_code.
{
"event": "documents.generation.failure",
"document": {
"id": "doc-uuid",
"status": "failure",
"filename": "invoice_Alice.pdf",
"download_url": null,
"public_share_link": null,
"output_type": "pdf",
"document_template_identifier": "my-invoice",
"failure_cause": "Snippet 'header' not found",
"failure_code": "snippet_not_found",
"meta": {},
"created_at": "2024-01-01T00:00:00.000Z",
"generated_at": null
}
}Signature verification (Node.js)
import crypto from 'crypto'
function verifyWebhook(secret, timestamp, rawBody, signature) {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
)
}Retry schedule
Failed deliveries (non-2xx response) are retried automatically up to 6 times:
Errors
All error responses are JSON with an error field; newer endpoints also mirror it as a stable code field — branch on codes, not on message strings, which may change.
| Status | error code | Cause |
|---|---|---|
| 400 | validation_error | Request body failed validation. Includes a details object. |
| 401 | unauthorized | Missing or invalid API key. |
| 403 | insufficient_scope | The API key's scope or template restriction does not allow this action (e.g. generate-only key modifying a template, restricted key using another template or raw_html). |
| 404 | not_found | Resource does not exist or belongs to a different account. |
| 400 | invalid_url | Webhook URL is not a public http(s) address (points to an internal/private host). |
| 400 | empty_template | Tried to publish a template whose draft body is empty. |
| 400 | template_not_published / template_draft_empty | The requested template version has no body (e.g. /variables on an unpublished template — add ?source=draft). |
| 409 | identifier_taken | Template identifier already in use for this account. |
| 409 | idempotency_key_in_flight | A request with the same Idempotency-Key is still being processed. Retry shortly. |
| 413 | Payload Too Large | Request body exceeds 1 MB. Affects large raw_html or inline data-URI images — host large assets at a URL instead (see Images & assets). |
| 422 | template_parse_failed | Template body could not be parsed as Liquid (from /variables). |
| 422 | unresolved_variables | Generation request had strict_variables: true and the payload leaves at least one template variable unresolved. Includes an unresolved_variables array of the missing paths. |
| 404 | template_version_not_found | The template_version pinned on a generation request (or the version in a versions/rollback URL) doesn't exist. |
| 429 | rate_limited | Too many requests. Check Retry-After header. |
| 429 | quota_exceeded | Monthly document quota reached. Upgrade or wait for the next billing cycle. |
Generation failure codes
When generation fails, the document card (and webhook payload) carries a failure_code alongside the human-readable failure_cause:
| failure_code | Meaning |
|---|---|
| template_render_failed | Liquid rendering failed — check syntax and payload variables. |
| snippet_not_found | The template references a snippet that doesn't exist. |
| style_compile_failed | SCSS compilation failed. |
| template_not_published | Generation attempted against a template with no published body. |
| render_failed | The HTML could not be rendered to the output format. |
| render_unavailable | The rendering service was temporarily unavailable — safe to retry. |
| postprocess_failed | PDF post-processing (forms, normalization, password) failed. |
| storage_failed | The generated file could not be stored. |
| no_input | Neither template nor raw HTML was available at generation time. |
| template_version_not_found | The pinned template_version no longer exists. |
| internal_error | Anything else. Contact support if it persists. |
// 400 validation_error
{
"error": "validation_error",
"details": {
"fieldErrors": { "document_template_id": ["Invalid uuid"] },
"formErrors": []
}
}
// 429 quota_exceeded
{
"error": "quota_exceeded",
"message": "Monthly quota of 20 documents reached",
"upgrade_url": "https://pdfkraft.com/dashboard/billing"
}Liquid templating
Template bodies use LiquidJS syntax. Variables from payload are available directly in the template. A missing variable renders as an empty string rather than an error — validate your payload contract up front with GET /document_templates/:id/variables (static, template-pick time) and unresolved_variables on every generation response (dynamic, this specific payload — see Documents) so a template edited after integration doesn't silently start shipping blank fields.
<!-- Variables -->
<p>Hello, {{ customer_name }}!</p>
<!-- Conditionals -->
{% if total > 1000 %}
<p class="discount">10% loyalty discount applied</p>
{% endif %}
<!-- Loops -->
{% for item in line_items %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.price }}</td>
</tr>
{% endfor %}
<!-- Reusable snippets (created in the dashboard) -->
{% render 'company-header' %}Snippets
Snippets are reusable Liquid fragments (headers, footers, address blocks) included with {% render 'name' %}. They work in template bodies and in raw_html (when a payload is supplied). Names are lowercase letters, digits, and hyphens. Manage them in the dashboard or via GET/POST /snippets and GET/PUT/DELETE /snippets/:id (dashboard session only, body {name, body}). Rendering a document that references a missing snippet fails with failure_code: snippet_not_found.
Images & assets
Documents are rendered by a real browser engine, so templates and raw_html can use images, web fonts, and stylesheets exactly like a web page. There are three ways to get an image into a document:
| Method | Use for |
|---|---|
| Static URL in the template | Fixed branding — logos, watermarks, backgrounds. <img src="https://your-cdn.com/logo.png"> |
| URL from the payload | Per-document images such as product photos: put <img src="{{ product.image_url }}"> in the template and send the URL in payload. |
| Inline data URI | Small images sent directly in the request, no hosting needed: <img src="data:image/png;base64,..."> — either hardcoded or passed through the payload. |
// Template
<h1>Invoice {{ number }}</h1>
{% for item in items %}
<img src="{{ item.image_url }}" width="120">
<p>{{ item.name }} — {{ item.price }}</p>
{% endfor %}
// Payload
{
"number": "INV-001",
"items": [
{ "name": "Desk lamp", "price": "€49",
"image_url": "https://your-cdn.com/products/lamp.jpg" }
]
}- Asset URLs must be publicly reachable: requests to loopback, link-local, or private (RFC 1918) addresses are blocked by the renderer.
- The request body is limited to 1 MB, which caps inline data-URI images at roughly 700 KB of image data. For anything larger, host the image and pass its URL.
- An image URL that is unreachable or returns an error does not fail generation — the document renders with a broken-image placeholder. Verify your URLs.
- The same rules apply to web fonts and external stylesheets.
- In page
header_html/footer_html, use data URIs — external images are not loaded there.
Plan limits
| Limit | Free | Starter | Pro |
|---|---|---|---|
| Documents / month | 20 | 500 | 5,000 + €0.005 overage |
| Document retention | 1 day | 1 day | 30 days |
| Sync generation timeout | 30 s | 30 s | 120 s |
| Public share links | No | No | Yes |
| Trial (new accounts) | 30-day Pro trial · 300 document cap · No card required | ||