PDFgen API
Generate pixel-perfect PDFs from your templates, HTML, or Markdown with a single REST call.
Introduction
All endpoints are served under /api/v1 and accept & return JSON (PDF endpoints can also return the binary file directly). The base URL is your PDFgen app domain:
https://pdfgen.com/api/v1
Send Content-Type: application/json on requests with a body. Every request must be authenticated with an API key (see below).
Prefer to click around first? Run live requests against /api/v1/generate with your own key in the API playground — no setup needed.
Authentication
Authenticate by sending your API key in either the x-api-key header or as a bearer token. Create and manage keys on the Developer page.
# Either header works:-H "x-api-key: pdfg_live_xxxxxxxxxxxxxxxx"-H "Authorization: Bearer pdfg_live_xxxxxxxxxxxxxxxx"
Keys are shown once at creation — store them securely. A missing or invalid key returns 401 unauthorized.
Errors & rate limits
Errors return a consistent JSON envelope:
{"success": false,"error": { "code": "invalid_request", "message": "No template_id present" }}
| Field | Type | Description |
|---|---|---|
| unauthorized | 401 | Missing, invalid, or revoked API key, or no active plan. |
| invalid_request | 400 | Missing or invalid parameters. |
| not_found | 404 | Template not found (or not owned by you). |
| method_not_allowed | 405 | Wrong HTTP method for this endpoint. |
| quota_exceeded | 429 | Monthly credit limit reached — upgrade your plan. |
| rate_limited | 429 | Too many requests in a short window. Respect Retry-After. |
| render_failed | 500 | The PDF could not be generated. |
| internal | 500 | Unexpected server error. |
Credits & billing: generation is metered in credits — 1 credit per 5 pages (a 1–5 page PDF = 1 credit, 6–10 = 2, and so on). Only successful (2xx) generations are charged; template management and log reads are free.
Rate limits: requests are limited per API key, per minute, by plan — 120 requests/min on Starter and 240 requests/min on Business. Exceeding the limit returns 429 rate_limited with a Retry-After header (seconds to wait); back off and retry.
Generate a PDF
Every request renders from one of three sources — choose whichever fits your workflow:
- 1From a template —
template_id
Render a saved, reusable template and merge in your data. - 2From HTML —
html
Send your own HTML markup (with optional {{handlebars}} tokens). - 3From Markdown —
markdown
Send Markdown — we style it into a clean, professional PDF.
Provide exactly one of template_id, html, or markdown. The options below apply to whichever source you choose.
| Field | Type | Description |
|---|---|---|
| template_id | string | Id of a saved template to render. Provide this, html, OR markdown. |
| html | string | Raw HTML, optionally with {{handlebars}} tokens. Provide this, template_id, OR markdown. |
| markdown | string | Markdown source, rendered with built-in typography. Provide this, template_id, OR html. |
| data | object | Values merged into the {{fields}} (template_id / html only). Omitted fields render empty. |
| engine | string | Only for html: "handlebars" (default) or "legacy". Templates use their own engine. |
| export_type | string | "file" (default) returns the PDF binary; "url" returns a signed link. |
| file_name | string | File name (without extension) for the generated PDF. |
| format | string | Page format: A4 (default), Letter, Legal, A3, A5, Tabloid. |
| expiry_time | number | For export_type=url: link lifetime in minutes (1–10080, default 5). |
| protect_pdf | boolean | Password-protect the PDF. |
| password | string | Required when protect_pdf is true. |
| self_storage | boolean | Also archive a copy to your own S3 bucket (Business plan). The returned url is still a managed signed link; the bucket copy is for your records. |
From a template
curl -X POST https://pdfgen.com/api/v1/generate \-H "Authorization: Bearer pdfg_live_xxx" \-H "Content-Type: application/json" \-d '{"template_id": "clx123abc","data": {"customer_name": "Acme Inc","invoice_number": "INV-1024","line_items": [{ "name": "Widget", "qty": 2, "amount": "20.00" }]},"export_type": "url","format": "Letter"}'
From raw HTML
curl -X POST https://pdfgen.com/api/v1/generate \-H "Authorization: Bearer pdfg_live_xxx" \-H "Content-Type: application/json" \-d '{"html": "<h1>Hello {{name}}</h1><ul>{{#each items}}<li>{{this}}</li>{{/each}}</ul>","data": { "name": "Acme", "items": ["A", "B"] },"export_type": "url"}'
From Markdown
curl -X POST https://pdfgen.com/api/v1/generate \-H "Authorization: Bearer pdfg_live_xxx" \-H "Content-Type: application/json" \-d '{"markdown": "# Invoice\n\n- Widget x2 — $20.00\n- Setup fee — $50.00\n\n**Total: $70.00**","format": "A4","export_type": "url"}'
Response (export_type=url):
{ "url": "https://...signed-link...pdf" }
With self_storage: true the response also includes "archived": true to confirm a copy was stored in your bucket.
With export_type=file (the default) the response body is the raw PDF with Content-Type: application/pdf.
Deprecated aliases: POST /api/v1/create (template) and POST /api/v1/render (html) still work and forward here — prefer /generate for new integrations.
Templates
Manage templates programmatically. These endpoints don't count against your PDF quota.
List templates
{"data": [{ "id": "clx123", "name": "Invoice", "engine": "handlebars", "createdAt": "...", "updatedAt": "..." }],"total": 12,"limit": 50,"offset": 0}
Create a template
| Field | Type | Description |
|---|---|---|
| name | string · required | Human-readable template name. |
| content | string · required | HTML with Handlebars tokens. |
| engine | string | "handlebars" (default) or "legacy". |
curl -X POST https://pdfgen.com/api/v1/templates \-H "Authorization: Bearer pdfg_live_xxx" \-H "Content-Type: application/json" \-d '{ "name": "Receipt", "content": "<h1>{{title}}</h1>" }'# 201 Created{ "data": { "id": "clx999", "name": "Receipt", "engine": "handlebars", "createdAt": "...", "updatedAt": "..." } }
Get / update / delete a template
GET returns the full template (including content). PUT/PATCH accepts any of name, content, engine. DELETE returns { "data": { "id": "...", "deleted": true } }. All are scoped to your account — a template you don't own returns 404.
Usage logs
Returns your recent API calls — useful for debugging and auditing.
{"data": [{"id": "...", "endpoint": "/api/v1/generate", "method": "POST","statusCode": 200, "durationMs": 1840, "billed": true,"templateId": "clx123", "createdAt": "..."}],"limit": 50, "offset": 0}
Templating (Handlebars)
Templates and the /render HTML use Handlebars. Values are HTML-escaped by default.
| Field | Type | Description |
|---|---|---|
| {{field}} | value | Insert a scalar value, e.g. {{customer_name}}. |
| {{#each items}}…{{/each}} | loop | Repeat for each item in an array; use {{this.x}} inside. |
| {{#if flag}}…{{/if}} | conditional | Render a block only when the value is truthy. |
| formatCurrency / formatDate / eq | helpers | Built-in helpers, e.g. {{formatCurrency total "USD"}}. |
<table>{{#each line_items}}<tr><td>{{this.name}}</td><td>{{formatCurrency this.amount "USD"}}</td></tr>{{/each}}</table>{{#if is_paid}}<span>PAID</span>{{/if}}
Verifying in the editor. The Data fields panel lists every detected field; array loops appear as amber {{#each …}} chips. Click Test PDF with sample data to render a real PDF — each array is auto-filled with two rows so you can see the loop repeat.
No math in templates. Totals can't be summed inside a loop — compute them in your code and pass the result as a field, e.g. {{formatCurrency grand_total "USD"}}.
Conditions (showing & hiding sections)
Wrap any part of the template in {{#if field}}…{{/if}} to render it only when that field has a value. Use {{#unless field}} for the opposite, and {{else}} for a fallback. The same field still goes in your data — its value decides whether the block appears.
{{#if discount}}<tr><td>Discount</td><td>-{{formatCurrency discount "USD"}}</td></tr>{{/if}}{{#if is_paid}}<span class="badge">PAID</span>{{else}}<span class="badge">DUE</span>{{/if}}{{#unless shipping_address}}<p>Pickup in store</p>{{/unless}}
What counts as "has a value" — the block shows when the field is truthy:
| Field | Type | Description |
|---|---|---|
| "15.00", 15, true | shows | Any non-empty string, non-zero number, true, or non-empty array. |
| false, 0, "" | hidden | Falsy values hide an {{#if}} block (and show an {{#unless}} one). |
| field omitted | hidden | A field left out of data is treated as empty — the block is hidden. |
So you toggle a section purely from the data you send — no template change needed:
// shows the discount row{ "template_id": "tmpl_123", "data": { "discount": 15 } }// hides it (omitted or falsy){ "template_id": "tmpl_123", "data": {} }
To compare values rather than test truthiness, use the eq helper: {{#if (eq status "paid")}}…{{/if}}.
In the editor, every {{#if}}/{{#unless}} section shows up in the Conditional sections panel. Remember the Test PDF always renders the visible branch (sample data is truthy) — to preview the hidden state, send the field as false or omit it in the API tab.
Page formats
Pass format on /create or /render (default A4):
| Field | Type | Description |
|---|---|---|
| A4 | format | A4 — 210 × 297 mm |
| Letter | format | US Letter — 8.5 × 11 in |
| Legal | format | Legal — 8.5 × 14 in |
| A3 | format | A3 — 297 × 420 mm |
| A5 | format | A5 — 148 × 210 mm |
| Tabloid | format | Tabloid — 11 × 17 in |