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" }
}
FieldTypeDescription
unauthorized401Missing, invalid, or revoked API key, or no active plan.
invalid_request400Missing or invalid parameters.
not_found404Template not found (or not owned by you).
method_not_allowed405Wrong HTTP method for this endpoint.
quota_exceeded429Monthly credit limit reached — upgrade your plan.
rate_limited429Too many requests in a short window. Respect Retry-After.
render_failed500The PDF could not be generated.
internal500Unexpected 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

POST/api/v1/generate

Every request renders from one of three sources — choose whichever fits your workflow:

  1. 1From a template template_id
    Render a saved, reusable template and merge in your data.
  2. 2From HTML html
    Send your own HTML markup (with optional {{handlebars}} tokens).
  3. 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.

FieldTypeDescription
template_idstringId of a saved template to render. Provide this, html, OR markdown.
htmlstringRaw HTML, optionally with {{handlebars}} tokens. Provide this, template_id, OR markdown.
markdownstringMarkdown source, rendered with built-in typography. Provide this, template_id, OR html.
dataobjectValues merged into the {{fields}} (template_id / html only). Omitted fields render empty.
enginestringOnly for html: "handlebars" (default) or "legacy". Templates use their own engine.
export_typestring"file" (default) returns the PDF binary; "url" returns a signed link.
file_namestringFile name (without extension) for the generated PDF.
formatstringPage format: A4 (default), Letter, Legal, A3, A5, Tabloid.
expiry_timenumberFor export_type=url: link lifetime in minutes (1–10080, default 5).
protect_pdfbooleanPassword-protect the PDF.
passwordstringRequired when protect_pdf is true.
self_storagebooleanAlso 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

GET/api/v1/templates?limit=50&offset=0
{
"data": [
{ "id": "clx123", "name": "Invoice", "engine": "handlebars", "createdAt": "...", "updatedAt": "..." }
],
"total": 12,
"limit": 50,
"offset": 0
}

Create a template

POST/api/v1/templates
FieldTypeDescription
namestring · requiredHuman-readable template name.
contentstring · requiredHTML with Handlebars tokens.
enginestring"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/api/v1/templates/:id
PUT / PATCH/api/v1/templates/:id
DELETE/api/v1/templates/:id

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

GET/api/v1/logs?limit=50&offset=0

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.

FieldTypeDescription
{{field}}valueInsert a scalar value, e.g. {{customer_name}}.
{{#each items}}…{{/each}}loopRepeat for each item in an array; use {{this.x}} inside.
{{#if flag}}…{{/if}}conditionalRender a block only when the value is truthy.
formatCurrency / formatDate / eqhelpersBuilt-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:

FieldTypeDescription
"15.00", 15, trueshowsAny non-empty string, non-zero number, true, or non-empty array.
false, 0, ""hiddenFalsy values hide an {{#if}} block (and show an {{#unless}} one).
field omittedhiddenA 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):

FieldTypeDescription
A4formatA4 — 210 × 297 mm
LetterformatUS Letter — 8.5 × 11 in
LegalformatLegal — 8.5 × 14 in
A3formatA3 — 297 × 420 mm
A5formatA5 — 148 × 210 mm
TabloidformatTabloid — 11 × 17 in