Build with instadoc
Upload a Word, Excel, PDF or PowerPoint template, send a JSON payload, get back a filled document. This guide takes you from sign-up to your first render in three steps.
Quickstart
Sign up & create an API key
Create an account — then visit Dashboard → API keys and click New API key. The full key body is shown once in a yellow banner. Copy it into your environment as INSTADOC_KEY.
API keys look like ido_live_aB3cD4eFgH7iJ8kL9mN0pQ1r — 32 chars, ~143 bits of entropy.
Upload a template
Templates are .docx / .xlsx / .pdf / .pptx files, max 10 MB. Macro-enabled formats (.docm, .xlsm, .pptm) are rejected outright; we also open every Office archive and reject anything shipping vbaProject.bin even if the extension is innocent.
curl -X POST http://localhost:8000/v1/templates \-H "Authorization: Bearer $INSTADOC_KEY" \-F "file=@./contract.docx" \-F "name=contract"
Render it
POST /v1/render with a JSON payload of replacements. The response body is the filled document. Add ?format=pdf to convert a docx / xlsx / pptx render to PDF, optionally with &orientation=landscape.
curl -X POST "http://localhost:8000/v1/render?template_id=$TPL" \-H "Authorization: Bearer $INSTADOC_KEY" \-H "Content-Type: application/json" \-d '{"client_name":"Acme Inc.","total":1234.50}' \--output filled.docx# Same call, but get a PDF back instead of .docx — add ?format=pdf.# Goes through the Gotenberg/LibreOffice sidecar.curl -X POST "http://localhost:8000/v1/render?template_id=$TPL&format=pdf" \-H "Authorization: Bearer $INSTADOC_KEY" \-H "Content-Type: application/json" \-d '{"client_name":"Acme Inc.","total":1234.50}' \--output filled.pdf
Authentication
Every /v1/* endpoint requires a Bearer token. There are two kinds, and you only ever need the first:
- API keys (
ido_live_…) — long-lived, issued from the dashboard, used by your servers. Stored only as the 8-char prefix plus an argon2id hash; we cannot recover the full key after creation. - Clerk JWTs — short-lived, used by the instadoc dashboard itself. You don't need these.
Send the API key in the Authorization header as Bearer ido_live_…. Verification uses constant-time argon2id, then re-checks the key isn't revoked or expired and the owning user isn't suspended.
Set expires_at when creating a key to auto-expire it. You can rotate or revoke any key from the dashboard.
Templates
A template is your source document with placeholder markers. Upload once, render any number of times.
| Method | Path | Description |
|---|---|---|
| POST | /v1/templates | multipart upload, returns the new template |
| GET | /v1/templates | list your templates |
| GET | /v1/templates/{id} | fetch one template |
| DELETE | /v1/templates/{id} | soft-delete (file removed from storage) |
List your templates
curl http://localhost:8000/v1/templates \-H "Authorization: Bearer $INSTADOC_KEY"
The response includes sha256, file size, detected placeholder_schema (once the render pipeline ships), and timestamps.
Writing templates
Templates are normal Word documents authored in any editor. Drop Jinja2 syntax anywhere you'd type text:
{{ field }}— inline substitution (single-line text).{% for x in items %} … {% endfor %}— loops over array values. Put the for / endfor pair on their own lines so docx paragraphs aren't merged.{% if cond %} … {% endif %}— conditionals. Missing fields are falsy by default.
Loops with tables
Plain {% for %} works for inline text, but Word docs are trees of paragraphs and tables — for visual structure you have two purpose-built placeholder forms:
{%tr for x in items %}/{%tr endfor %}— each marker lives in its own table row. Those marker rows are consumed; the row(s) in between are what get repeated. Header rows above the loop stay put.{%p for x in items %}/{%p endfor %}— each marker lives in its own paragraph. Everything between them — paragraphs, whole tables, images — is duplicated per iteration. Use this for one fully-styled block per item.
Pattern A — header + repeated body rows in one table:
# In your .docx, build a 4-row table:| Title | Client | Value | ← header row (kept)| {%tr for wh in workHighlights %} | | | ← marker row (consumed)| {{ wh.content.commercialTitle }} | {{ wh.matter.clients[0].name }} | {{ wh.matter.dealValueFormatted }} || {%tr endfor %} | | | ← marker row (consumed)# Output: header + N body rows, one per workHighlight.
Pattern B — one whole styled table per item:
# Two paragraphs sandwiching a table:{%p for wh in workHighlights %}| Title | {{ wh.content.commercialTitle }} || Client | {{ wh.matter.clients[0].name }} || Description | {{p wh.content.commercialDescription | html }} || Value | {{ wh.matter.dealValueFormatted }} |{%p endfor %}# Output: N independent, fully-styled tables.
docxtpl moves the {% for %} / {% endfor %} tags out to row / paragraph boundaries before compiling the template, so the repeated structure stays as valid Word markup.
Excel: row loops with {%row%}
In an .xlsx template, drop {%row for x in items %} into any cell. The entire row gets repeated once per iteration — and the marker cell is wiped on each copy. Cells in the same row reference x normally. Rows below the loop slide down to make room.
# Sheet1 before render:| Name | Amount || {%row for c in clients %} | {{ c.name }} | {{ c.amount }} || Total | =SUM(B2:B99) |# Output with clients=[{name:"Acme",amount:100},{name:"BMW",amount:200}]:| Name | Amount || Acme | 100 || BMW | 200 || Total | =SUM(B2:B99) |
Empty arrays drop the marker row entirely. Formulas in static rows are preserved untouched — Excel still evaluates them on open.
Collapsing arrays
Two custom Jinja filters keep templates terse when your data has nested arrays — no pre-processing layer required:
| csv(attr=None, sep=', ')— join an iterable into a single string. Pass an attribute name to pull from each item; works for both dicts and objects. Missing entries are skipped so a partial dataset doesn't leave dangling commas.| lines(*attrs)— emit one Word paragraph per item. With one attr each item shows that field. With two-plus, the first is the primary and the rest are appended in parentheses (e.g.Robbert Vries (Senior Associate)). Use with the paragraph-level form{{p var | lines(...) }}so items land on their own lines — a literal `\n` inside a run reads as whitespace in Word, not a break.
# Inline CSV:Clients: {{ w.matter.clients | csv('name') }}→ "Clients: Acme Corp, BMW Group, Shell plc"# One paragraph per lead specialist (with role in parens):{{p w.leadSpecialists | lines('fullName', 'role') }}→ Robbert Vries (Senior Associate)Jorn Vermeulen (Associate)Warren Wuckert (Paralegal)# Plain-string iterable: one line per press link:{{p w.matter.pressLinks | lines }}→ https://example.com/coverage-onehttps://example.com/coverage-two# Custom separator:{{ tags | csv(attr='label', sep=' · ') }}→ "litigation · tax · m&a"
HTML content fields
When your payload contains an HTML string (e.g. <p>Hello <strong>world</strong></p>), piping it through the html filter renders real Word paragraphs and runs instead of literal tags. The placeholder must use the paragraph-level form {{p … }}:
# In your .docx, on its own paragraph line:{{p description | html }}# Renders <p>, <strong>, <em>, <u>, <ul>/<ol>/<li>, <h1>–<h6>, <br>, <a>.# <script>, <style>, <iframe>, <object>, <embed>, <link>, <meta>, <noscript># are stripped entirely — tag AND contents.
Use the inline form {{ field | html }} only when you know the content has no block-level tags and you want it inside an existing paragraph — but the {{p}} variant is the safe default for any HTML field.
Escaping (and why)
Every plain {{ field }} substitution is XML-escaped before it hits the document. So a value containing <, >, or& renders as visible text — not as an OOXML tag. This matters: without escaping, a single <p> in your payload would corrupt the document so Word refuses to open it.
The practical consequence: if a payload field stores HTML and you reference it with a plain placeholder, you'll see the literal tags as text in the rendered Word doc:
# Payload: { "bio": "<p>Senior <strong>partner</strong>.</p>" }# Template uses a plain placeholder:{{ bio }}→ Word shows: <p>Senior <strong>partner</strong>.</p> ✗ ugly# Pipe through | html to get real paragraphs / runs instead:{{p bio | html }}→ Word shows: Senior partner. ✓ bold + paragraph# Escape hatch for trusted, already-OOXML-safe text (rare):{{ field | safe }} ⚠ skip the escape — only use when you authored both sides
In short: plain text → {{ field }}; rich HTML → {{p field | html }}; pre-formatted OOXML (you really shouldn't need this) → {{ field | safe }}.
Rendering
POST /v1/render?template_id=…
Body: a JSON object whose keys match the placeholders in the template. Size cap MAX_PAYLOAD_BYTES (100 KiB default), max depth 5, max 500 keys. Control characters and base64 blobs > 50 KiB are rejected.
Response: the filled document as a binary stream with the appropriate Content-Type. Add ?format=pdf to convert any docx / xlsx / pptx render to PDF, optionally with &orientation=portrait|landscape.
Replace ?template_id=… with ?composition_id=… to render every block in a composition into a single output. The same ?format=pdf override applies.
Compositions
A composition is an ordered, optionally-conditional list of template blocks rendered into one document. Every output format is supported:
- .docx — concatenated bodies, page break between blocks. Block 1's styles win on conflicts.
- .xlsx — every source sheet becomes its own sheet in the merged workbook (names uniquified). Per-cell styles round-trip; cross-workbook charts on blocks 2..N may drop.
- .pptx — slides cloned into the first block's deck (which sets the masters). Speaker notes don't survive the clone today.
- .pdf — page-level concatenation via pypdf. Each source becomes a top-level "Block N" bookmark. Encrypted source PDFs are rejected.
Hard caps: 50 blocks per composition, 200 slides or pages per render. Use include_when on a block to skip it based on payload data — the expression runs in the same sandbox as template Jinja.
| Method | Path | Description |
|---|---|---|
| POST | /v1/compositions | create a composition (name + doc_type) |
| GET | /v1/compositions | list your compositions |
| GET | /v1/compositions/{id} | fetch one with its blocks |
| DELETE | /v1/compositions/{id} | soft-delete |
| POST | /v1/compositions/{id}/blocks | add a template block at a position |
| DELETE | /v1/compositions/{id}/blocks/{block_id} | remove a block |
Themes
A theme is a shared .potx-style master deck. Upload it once; the server computes a master_signature SHA-256 from the deck's slide-master tree so every PPTX template you later link to the theme can be checked against it.
| Method | Path | Description |
|---|---|---|
| POST | /v1/themes | upload (multipart: file + name) |
| GET | /v1/themes | list your themes |
| GET | /v1/themes/{id} | fetch one |
| PATCH | /v1/themes/{id} | rename |
| DELETE | /v1/themes/{id} | soft-delete |
API keys
Manage keys from the dashboard or via the API:
| Method | Path | Description |
|---|---|---|
| POST | /v1/keys | create — returns the full key body ONCE |
| GET | /v1/keys | list (prefix only, never the full body) |
| PATCH | /v1/keys/{id} | rename, change scopes, change expiry |
| POST | /v1/keys/{id}/rotate | revoke old + issue new with same name/scopes |
| POST | /v1/keys/{id}/revoke | terminate a key |
| DELETE | /v1/keys/{id} | hard-delete a revoked or expired key |
Error codes
Every error response is JSON: { "detail": "human-readable string" }. The HTTP status is the part you switch on:
HTTP/1.1 415 Unsupported Media Typecontent-type: application/json{"detail": ".docm files are not accepted (macro-enabled)"}
| Status | When | Recovery |
|---|---|---|
| 400 | request body fails validation | check schema |
| 401 | missing / invalid bearer token, or unknown user | re-check the key; rotate if you suspect leakage |
| 403 | authenticated but not allowed (non-admin hit admin route) | use a permitted endpoint |
| 404 | not found (or belongs to another user — same response, by design) | check the id |
| 409 | state conflict (e.g. delete on an active API key) | read the detail string |
| 413 | file or payload too large | stay under the limit (32 MB / 100 KiB) |
| 415 | unsupported format, MIME mismatch, macro file | use .docx/.xlsx/.pdf/.pptx without macros |
| 423 | account suspended | contact support |
| 429 | rate limit hit — check X-RateLimit-* | back off until X-RateLimit-Reset |
| 501 | ?format=pdf requested but GOTENBERG_URL isn't configured on the server | drop the format query, or contact the operator |
| 502 | upstream storage failed (DO Spaces / local fs) | retry with jittered backoff |
Rate limits
The per-key ceiling depends on your plan — there's also a flat 300 requests / minute / IP applied independently. Whichever you hit first returns 429.
| Plan | Requests / min / API key | Renders / month |
|---|---|---|
| Starter | 60 | 100 |
| Growth | 600 | 500 |
| Scale | 6,000 | 5,000 |
Every API-key-authenticated response includes:
X-RateLimit-Limit— your per-key ceilingX-RateLimit-Remaining— calls left in the current minuteX-RateLimit-Reset— Unix timestamp when the bucket clears
"Faster queue priority" and "Highest queue priority" in the pricing table refer to these per-key ceilings — there's no separate queue.
Versioning
The API lives under /v1 and is intended to stay there for a long time. We classify changes:
- Additive (new endpoint, new optional field, new status code, new header) — shipped at any time on
/v1. - Breaking (removed field, changed type, changed status code semantics) — only on
/v2./v1continues serving traffic for at least 6 months after the cut.
We list everything in the interactive API reference (auto-generated from the live OpenAPI spec).