Everything you need to go from an API key to a rendered PDF: authentication, the request/response shape, every field the standard invoice template understands, and the full error reference.
The /v1/render endpoint requires a Pro-plan API key. Free accounts use the
web builder at the dashboard instead — there's no API key on
the Free plan.
Once you're on Pro (upgrade here), open the
API Access panel on your dashboard and click
Generate API key. The raw key (starts with df_) is shown
exactly once — only its hash is stored server-side, so if you lose
it, click Regenerate API key to issue a new one. Regenerating immediately
revokes the old key, so update anywhere it's in use before you do.
Send your key as a bearer token on every request to /v1/render:
Authorization: Bearer df_your_api_key_here
A missing header returns 401. A revoked or malformed key also returns
401 — DocuForge deliberately doesn't distinguish the two, so a key
can't be probed for validity.
Render the standard invoice template with a couple of line items:
curl https://your-docuforge-host/v1/render \
-H "Authorization: Bearer df_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tpl_inv_98231",
"data": {
"invoice_number": "INV-2026-001",
"client_name": "Acme Corp",
"company_name": "Your Company Ltd.",
"brand_color": "#4F46E5",
"line_items": [
{ "desc": "Consulting", "qty": 4, "price": 150.00 }
]
}
}' \
--output invoice.pdf
const res = await fetch("https://your-docuforge-host/v1/render", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.DOCUFORGE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
template_id: "tpl_inv_98231",
data: {
invoice_number: "INV-2026-001",
client_name: "Acme Corp",
line_items: [{ desc: "Consulting", qty: 4, price: 150.0 }],
},
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(error);
}
const pdfBytes = Buffer.from(await res.arrayBuffer());
A successful response is the raw PDF bytes, with Content-Type: application/pdf.
Two extra headers come along for observability: X-Render-Time-Ms (server-side
render time) and X-Render-Engine: rust-core.
The core endpoint: hydrates a template with JSON data and returns a PDF. Requires API key authentication. No monthly cap and no watermark on Pro — those only apply to the free web builder.
| Field | Type | Description |
|---|---|---|
template_id | string, required | Which template to render. Use tpl_inv_98231 for the built-in standard invoice, or the id of a template you registered via POST /v1/templates. |
data | object | The JSON hydrated into the template. See Invoice data fields for what the standard template expects. |
options.page_size | string | "A4" (default) or "Letter". |
options.margin | string | CSS-style length: "15mm" (default), "0.5in", or a bare number (assumed millimeters). |
options.async | boolean | Not implemented yet — see roadmap. Always returns 400 if true. |
These are the fields the built-in standard invoice template (tpl_inv_98231)
reads out of data. A custom template only sees whatever fields you put in
data, plus the three DocuForge always injects (brand_color,
watermark, payment_block).
| Field | Type | Notes |
|---|---|---|
invoice_number | string | Shown in the header. Not validated or auto-generated by the API — the web builder generates a default, your integration should too. |
client_name | string | Rendered under "Billed To". |
company_name | string | Rendered under "From". Plain text only via the API — logo upload is a web-app-only feature. |
brand_color | string | Hex color for the header/accent, e.g. #4F46E5. Defaults to DocuForge's indigo if omitted. |
line_items | array | Each item: { "desc": string, "qty": number, "price": number }. Subtotals are computed server-side. |
amount_due | string | Optional, display only. If omitted, it's computed as the sum of qty × price across line_items, formatted as €0.00. Note this is not what the payment QR code (below) encodes — that always uses the real computed total. |
iban | string | Optional, EU/SEPA accounts. If present and valid, a SEPA/EPC "pay by bank transfer" QR code plus the bank name/IBAN/BIC/amount in text are added to the PDF. Spaces are ignored; must pass the standard IBAN checksum or the request fails with 400. Takes priority over account_number if both are present — and the text underneath the QR works for international payers too, since an IBAN account can also receive a SWIFT wire from outside SEPA using the IBAN as the account number. |
bic | string | Optional. Used alongside either iban or account_number — BIC/SWIFT is a genuine global standard, not SEPA-specific. Worth setting whenever you set iban, not just for the non-SEPA fallback, since it's what an international payer's bank needs to route a wire to it. |
bank_name | string | Optional. Shown in the payment details either way — alongside iban so an international wire has a bank name to reference, or alongside account_number where it's the primary way to identify the bank. |
account_number | string | Optional, the non-IBAN fallback for banks outside the IBAN system entirely (most notably the US, which uses account + routing numbers instead of IBAN). No QR code — there's no scan-to-pay standard for arbitrary wire transfers — just the details spelled out in text. Alphanumeric only, 4–34 characters. Ignored if iban is also present, since IBAN already covers the international-wire case too. |
routing_number | string | Optional, only used alongside account_number. If it's exactly 9 digits it's validated as a US ABA routing number (checksum); other lengths are accepted as-is for non-US local bank codes. |
Both the QR code and the wire transfer amount always encode the sum of line_items (qty × price), the beneficiary name from company_name, and a remittance reference of Invoice <invoice_number> — not whatever string you passed as amount_due, since that field is display-only and could drift from the real total.
iban and account_number are encrypted at rest (AES-256-GCM) — decrypted only to show them back to you in your own dashboard and to build the payment block on your own invoices.
Templates are HTML/CSS with Jinja-style placeholders ({{ field }}, {% for %}, {% if %}), rendered through the same engine as the built-in invoice. All three endpoints require your API key, same as /v1/render.
| Endpoint | Description |
|---|---|
GET /v1/templates | List the built-in templates plus any you've registered. |
GET /v1/templates/{id} | Fetch one template's HTML. |
POST /v1/templates | Register a template: { "id": "optional", "name": "...", "html": "..." }. Returns the stored template, generating an id if you didn't supply one. |
tpl_inv_98231) are visible to everyone. An id owned by another
account behaves like it doesn't exist: you can't read it, and POST with
that same id returns 409 Conflict instead of silently overwriting it.
Errors are JSON: { "error": "human-readable message" }, with a status code
that tells you what kind of problem it was.
| Status | Meaning |
|---|---|
400 | Invalid request — bad JSON shape, unsupported page_size/margin, an invalid iban or routing_number checksum, an out-of-range account_number length, or a template render error (e.g. malformed Jinja). |
401 | Missing, malformed, or invalid/revoked API key. |
404 | template_id doesn't exist, or exists but is owned by a different account. |
409 | Registering a template (POST /v1/templates) with an id already owned by someone else. |
413 | Request body too large. |
429 | Rate or quota limit hit — see below. |
500 | PDF generation failed, or an internal error. Safe to retry; open an issue if it persists. |
| Surface | Limit |
|---|---|
POST /v1/render (Pro, API key) | Unlimited. |
| Web builder (Free plan) | 10 invoices/month, watermarked. |
| Web builder (Pro plan) | Unlimited, no watermark. |
POST /v1/demo/render (no account) | 20 renders/hour per IP, always watermarked. For trying the API without signing up — not a substitute for a key. |
options.async, queue-backed rendering, and object storage (S3/R2) for
large batch jobs are on the roadmap but not implemented — setting async: true
today returns a clear 400 rather than silently ignoring it. Every render is
currently synchronous: the PDF comes back in the same response.