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

1

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.

2

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"
3

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.

MethodPathDescription
POST/v1/templatesmultipart upload, returns the new template
GET/v1/templateslist 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-one
https://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.

MethodPathDescription
POST/v1/compositionscreate a composition (name + doc_type)
GET/v1/compositionslist your compositions
GET/v1/compositions/{id}fetch one with its blocks
DELETE/v1/compositions/{id}soft-delete
POST/v1/compositions/{id}/blocksadd 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.

MethodPathDescription
POST/v1/themesupload (multipart: file + name)
GET/v1/themeslist 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:

MethodPathDescription
POST/v1/keyscreate — returns the full key body ONCE
GET/v1/keyslist (prefix only, never the full body)
PATCH/v1/keys/{id}rename, change scopes, change expiry
POST/v1/keys/{id}/rotaterevoke old + issue new with same name/scopes
POST/v1/keys/{id}/revoketerminate 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 Type
content-type: application/json
{"detail": ".docm files are not accepted (macro-enabled)"}
StatusWhenRecovery
400request body fails validationcheck schema
401missing / invalid bearer token, or unknown userre-check the key; rotate if you suspect leakage
403authenticated but not allowed (non-admin hit admin route)use a permitted endpoint
404not found (or belongs to another user — same response, by design)check the id
409state conflict (e.g. delete on an active API key)read the detail string
413file or payload too largestay under the limit (32 MB / 100 KiB)
415unsupported format, MIME mismatch, macro fileuse .docx/.xlsx/.pdf/.pptx without macros
423account suspendedcontact support
429rate limit hit — check X-RateLimit-*back off until X-RateLimit-Reset
501?format=pdf requested but GOTENBERG_URL isn't configured on the serverdrop the format query, or contact the operator
502upstream 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.

PlanRequests / min / API keyRenders / month
Starter60100
Growth600500
Scale6,0005,000

Every API-key-authenticated response includes:

  • X-RateLimit-Limit — your per-key ceiling
  • X-RateLimit-Remaining — calls left in the current minute
  • X-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. /v1 continues 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).