DocMint API reference
Fill a Word, Excel or PowerPoint template with JSON and get the Office file, a PDF, or both. One base URL, one main endpoint, and an error that names the field it could not resolve.
Base URL https://docmint-832s.onrender.com — every path below is relative to it.
All request and response bodies are JSON except where a file's bytes are being sent or returned.
Every response carries an X-DocMint-Request-Id header; quote it in any bug report and it finds the exact log line.
Quickstart#
Four calls: get a key, upload a template, ask what it needs, render it. The whole thing takes about a minute and costs one credit.
1. Get a key
curl -X POST https://docmint-832s.onrender.com/v1/signup \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a-long-enough-password"}'
{
"email": "you@example.com",
"api_key": "dm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"plan": { "id": "free", "name": "Free", "credits": 30 },
"note": "This is the only time the key is shown. Store it now."
}2. Author a template
Open Word and type a document with placeholders in it. This one has two:
Quote for {{customer}}
Total due: {{total|currency:EUR}}Save it as quote.docx. Nothing else about the file changes — it still opens in Word, and whoever owns the letterhead can keep editing it.
3. Upload it
curl -X POST https://docmint-832s.onrender.com/v1/templates \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d "{\"name\":\"quote\",\"file_base64\":\"$(base64 -w0 quote.docx)\"}"
{
"request_id": "dm_4wtUEH44inC4",
"id": "tpl_6P2RxBHdMziH",
"name": "quote",
"format": "docx",
"version": 1,
"unchanged": false,
"size": 1001,
"fields": [
{ "name": "customer", "scope": "", "type": "string", "repeating": false,
"required": true, "formatters": [], "used": 1,
"locations": ["word/document.xml, paragraph 1"] },
{ "name": "total", "scope": "", "type": "number", "repeating": false,
"required": true, "formatters": ["currency"], "used": 1,
"locations": ["word/document.xml, paragraph 2"] }
],
"macro_enabled": false
}4. Ask what it needs, then render it
curl -H "Authorization: Bearer dm_live_xxx" \
https://docmint-832s.onrender.com/v1/templates/quote/fields{
"id": "tpl_6P2RxBHdMziH",
"name": "quote",
"format": "docx",
"version": 1,
"fields": [
{ "name": "customer", "scope": "", "type": "string", "repeating": false,
"required": true, "formatters": [], "used": 1,
"locations": ["word/document.xml, paragraph 1"] },
{ "name": "total", "scope": "", "type": "number", "repeating": false,
"required": true, "formatters": ["currency"], "used": 1,
"locations": ["word/document.xml, paragraph 2"] }
],
"names": ["customer", "total"],
"tags": [
{ "expr": "customer", "kind": "value",
"location": "word/document.xml, paragraph 1" },
{ "expr": "total|currency:EUR", "kind": "value",
"location": "word/document.xml, paragraph 2" }
],
"sample_data": { "customer": "customer", "total": 0 }
}curl -X POST https://docmint-832s.onrender.com/v1/render \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d '{"template":"quote","data":{"customer":"Acme GmbH","total":1240.5},"output":"pdf"}' \
--output quote.pdf
HTTP/2 200
content-type: application/pdf
content-length: 13660
content-disposition: attachment; filename="document.pdf"
x-docmint-request-id: dm_o9shbTxzJCqW
x-docmint-credits-remaining: 28
x-docmint-warnings: 0
x-ratelimit-limit: 120
x-ratelimit-burst: 30
x-ratelimit-remaining: 29Leave output off and you get the filled .docx instead, for one credit rather than two.
POST/v1/signup#
Self-serve signup over the API, deliberately rather than only through a web form. The buyer here is someone wiring up a workflow; making them leave the terminal before they can see whether the thing works is friction with no purpose.
| Field | Type | Notes |
|---|---|---|
email | string | Required. Lower-cased and trimmed. Must look like an address. |
password | string | Required, at least 10 characters. Stored only as a bcrypt hash. |
The response contains the account's first API key. It is shown once and cannot be read back — not by support, not by the operator, because only a SHA-256 hash of it is stored.
There is no password reset and no confirmation email, because this service sends no email at all. If you lose both the password and every key, the account cannot currently be recovered without the operator's help. Store the key.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_email | That does not look like an email address. |
| 400 | weak_password | Shorter than 10 characters. |
| 409 | email_taken | An account with that address exists. Make another key with POST /v1/keys using a key you already hold. |
| 429 | signup_rate_limited | One signup per minute per IP address. Not security — just enough to stop a loop in a misconfigured workflow filling the accounts table overnight. |
Authentication#
Every endpoint except POST /v1/signup, GET /v1/capabilities and GET /healthz needs a key.
Authorization: Bearer dm_live_xxxX-API-Key: dm_live_xxx is accepted too, for clients that cannot set an Authorization header. Keys always start with dm_live_; a key that does not is rejected without a database lookup.
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No Authorization or X-API-Key header was sent. |
| 401 | invalid_api_key | Not a DocMint key, or the key has been revoked. Revocation takes effect on the next request. |
More keys#
One key per environment is a good habit: a leaked key from a staging workflow can then be revoked without stopping production.
curl -X POST https://docmint-832s.onrender.com/v1/keys \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d '{"label":"ci"}'
{ "key": "dm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"note": "This is the only time the key is shown. Store it now." }Revoke one by its first 16 characters — the prefix, which is the only part of a key that is stored in the clear:
curl -X DELETE https://docmint-832s.onrender.com/v1/keys/dm_live_X67fKwVY \
-H "Authorization: Bearer dm_live_xxx"{ "revoked": 1 }Revoking the account's only key is refused, because a key you cannot replace without one is a lockout:
{
"error": {
"code": "last_key",
"message": "This is the only key on the account, so revoking it would lock you out.",
"hint": "Create a replacement key first, then revoke this one.",
"docs": "https://docmint-832s.onrender.com/docs#auth-more-keys",
"request_id": "dm_ZNH9iIxUU9yx"
}
}| Status | Code | Meaning |
|---|---|---|
| 404 | key_not_found | No live key on this account starts with that prefix. |
| 409 | last_key | Refusing to revoke the only key on the account. |
Template syntax#
The same syntax works in all three formats. Both brace styles are accepted, always: {name} is the docxtemplater and Carbone style, {{name}} is the mustache and n8n style. Supporting both costs one branch in the scanner and removes the most common first-five-minutes failure, which is typing the other one. No third syntax was invented.
Anything between braces that does not parse as a tag is left alone. A document containing { "total": 12 }, .a { color: red }, ${HOME} or an unclosed {oops renders unchanged. Real documents contain JSON samples, CSS and shell snippets, and erroring on them would make the product unusable for the people who write them.
Every tag form
| Form | Meaning | Worked example |
|---|---|---|
{name} | A value. | {customer} with {"customer":"Acme GmbH"} gives Acme GmbH. |
{{name}} | The same value, other braces. | Identical in every way. |
{a.b.c} | A dotted path. | {customer.name} with {"customer":{"name":"Acme GmbH"}} gives Acme GmbH. |
{items.0.sku}{items[1].sku} | Numeric index, either notation. | With {"items":[{"sku":"A-1"},{"sku":"B-2"}]} those give A-1 and B-2. |
{{[Customer Name]}} | A bracket-quoted key, for a field name containing spaces. This is the Docupilot form and it works here for that reason. | With {"Customer Name":"Acme GmbH"} gives Acme GmbH. |
{#items} … {/items} | A section: once per array element; once for an object; once for a truthy scalar; never for an empty array or a falsy value. | {#items}{sku} {/items} over two items gives A-1 B-2 . |
{^items} … {/items} | An inverted section: renders only when the value is absent, empty or falsy. Absence is normal here, not an error. | {^missing_list}nothing here{/missing_list} with no such key gives nothing here. |
{/} | Closes the innermost open section. Convenient inside table cells where repeating the name is noise. | {#items}{sku}{/} gives A-1B-2. |
{.} | The current scope itself, for arrays of scalars. | {#colours}{.} {/colours} with ["red","green","blue"] gives red green blue . |
{../x} | One scope outwards. An explicit jump: it looks only in that frame's chain. | {#items}{../company} {/items} gives Meridian Meridian . |
{$index} {$index1}{$first} {$last}{$length} | Loop metadata. $index is zero-based, $index1 one-based. | {#items}{$index}:{$index1}:{$first}:{$last}:{$length} {/items} over two items gives 0:1:true:false:2 1:2:false:true:2 . |
{%logo} | An image. See Images. | Works in DOCX, XLSX and PPTX. |
{@rawXml} | Raw OOXML, inserted unescaped. Powerful and unguarded — malformed markup here produces a file the Office app refuses to open. | {@custom} with "<w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r>" inserts a genuinely bold run. |
{!note} | A comment. Removed from the output. | before{!a note}after gives beforeafter. |
{price|currency:EUR} | A formatter pipeline. See Formatters. | Chains left to right: {items|sum:amount|currency:EUR}. |
Scopes
Inside a section the outer scopes stay visible, so {currency} from the invoice root resolves inside {#items} without writing {../currency}. That is a convenience with a sharp edge, and DocMint tells you about it every time — see Warnings.
Where a loop repeats
What "repeat" means depends on where the two markers sit, and this is what makes an invoice line-item table work:
| Markers are… | What repeats |
|---|---|
| In one paragraph | The runs between them. Runs are split at the marker positions first, so a section body is always a whole number of runs. |
| In different cells of one table row (Word, PowerPoint) | The whole row. This is the invoice line-item case: {#items} in the first cell, {/items} in the last. |
| In one row of a worksheet (Excel) | The whole row, and everything below it moves down: row indices, cell references, merged ranges, the dimension, conditional formatting, data validation, hyperlinks, autofilter, defined names, drawing anchors, and formulas. |
| Across several block-level children (paragraphs, tables) | The children strictly between the markers. A marker paragraph that holds nothing else is deleted, so a loop leaves no blank lines behind. |
Split runs are handled. Word stores {{name}} as two runs the moment someone edits the middle of the word, or spellcheck runs, or the file comes back from Google Docs. Every paragraph's runs are concatenated into one string, scanned, and written back by slicing — a tag's replacement goes entirely into the run that held its first character, so it inherits that run's formatting. A renderer that scans run by run misses every real-world template; this one does not.
Formatters#
43 of them, applied left to right with |. There is no expression evaluator: formatters are a fixed, audited list, so no string you send is ever compiled or executed. The canonical list is served by GET /v1/capabilities, read straight out of the module — a formatter cannot be documented into existence.
Arguments are separated by : and are trimmed. {n|number:2}, {total|currency:EUR}, {rows|sort:total:desc}. An argument may contain spaces in the middle ({note|default:not given}) but leading and trailing spaces are removed, so a separator such as ", " currently arrives as ",".
Text
| Formatter | Does | Worked example |
|---|---|---|
upper | UPPER CASE | {name|upper} of "acme gmbh b.v." gives ACME GMBH B.V. |
lower | lower case | {name|lower} of "ACME GmbH" gives acme gmbh |
title | Title Case, word by word | {name|title} of "acme gmbh b.v." gives Acme Gmbh B.v. |
trim | Removes surrounding spaces | [{padded|trim}] of " spaced " gives [spaced] |
join | Joins a list into text; the separator defaults to ", " | {tags|join:,} of ["alpha","beta","gamma"] gives alpha,beta,gamma |
yesno | Two words for a boolean | {paid|yesno:Paid:Unpaid} of true gives Paid |
default | Supplies a value when the field is absent, null or empty. The sanctioned way to let a field be missing. | [{nothere|default:not given}] gives [not given] |
ordinal | 1st, 2nd, 3rd. English only, and documented as such. | {three|ordinal} of 3 gives 3rd |
Numbers that produce text
These format for the request's locale and return a string. In a spreadsheet that means a text cell — see the note below.
| Formatter | Does | Worked example (locale en-US) |
|---|---|---|
number | Groups digits; the argument is 0 to 20 decimal places | {n|number:2} of 1234.5678 gives 1,234.57; {n|number:0} gives 1,235 |
currency | A 3-letter ISO code, defaulting to the request's currency | {n|currency:EUR} gives €1,234.57; {n|currency} with the default gives $1,234.57 |
percent | Multiplies by 100 and appends the locale's percent sign | {rate|percent:1} of 0.075 gives 7.5% |
date | A pattern, or one of short / medium / long / full. Accepts an ISO string, an epoch number, or anything Date can parse; refuses what it cannot rather than printing "Invalid Date" into a contract. Pattern tokens: YYYY YY MMMM MMM MM DD dddd ddd HH hh mm ss A a. | Of "2026-07-14": date:DD MMMM YYYY gives 14 July 2026; date:YYYY-MM-DD gives 2026-07-14; date:long gives July 14, 2026; date:ddd DD MMM YY gives Tue 14 Jul 26 |
Numbers that produce numbers
These return a real number. That distinction is load-bearing in a spreadsheet: a numeric result becomes a numeric cell and SUM() over the column works, whereas text lands as text and SUM() silently returns zero. Arithmetic is done in cents-safe form, so 0.1 + 0.2 never appears on an invoice.
| Formatter | Does | Worked example |
|---|---|---|
round | Rounds to N decimal places | {n|round:1} of 1234.5678 gives 1234.6 |
multiply | Multiplies by the argument | {n|multiply:1.19|round:2} of 1234.5678 gives 1469.14 |
add | Adds the argument | {n|add:100} of 1234.5678 gives 1334.5678 |
subtract | Subtracts the argument | {n|subtract:34.567} gives 1200.0008 |
divide | Divides; dividing by zero is an error, not an infinity | {n|divide:2} gives 617.2839 |
sum | Adds a field over a list, or adds a list of numbers. Every item must carry the field. | {items|sum:amount} over amounts 39, 16, 88, 19.5 gives 162.5 |
sumProduct | Multiplies two or more fields per row, then adds. Line totals, computed once. | {items|sumProduct:qty:price} over 2×19.5, 5×3.2, 1×88, 1×19.5 gives 162.5 |
count | How many items (1 for a non-null scalar, 0 for null) | {items|count} over four items gives 4 |
The invoice-total rule. {items|sumProduct:qty:price|currency:EUR} is the one to reach for. A template that has its total typed into it is a template that is wrong the first time a line item changes.
List shaping, for sections
These take a list and give back a list, so they belong on a section tag: {#items|filter:active|sort:due_date}. The alternative is telling you to sort and filter the array in your workflow before it arrives — which is fine until the same array has to be rendered twice in one document in two different orders.
| Formatter | Does | Worked example over A-1(hw,active) B-2(hw) C-3(sw,active) A-1(sw,active) |
|---|---|---|
filter | filter:field keeps rows whose field is truthy; filter:field:value keeps rows where it equals the value | {#items|filter:active}{sku} {/items} gives A-1 C-3 A-1 |
reject | The inverse of filter | {#items|reject:active}{sku} {/items} gives B-2 |
sort | sort:field or sort:field:desc. Numbers sort numerically; strings use the request locale's collator. | sort:sku gives A-1 A-1 B-2 C-3 ; sort:amount:desc gives C-3 A-1 A-1 B-2 |
reverse | Reverses the order | gives A-1 C-3 B-2 A-1 |
limit | Keeps the first N | limit:2 gives A-1 B-2 |
skip | Drops the first N | skip:2 gives C-3 A-1 |
unique | Keeps the first row for each distinct value of the field | unique:sku gives A-1 B-2 C-3 |
groupBy | Groups into {key, items, count}, preserving order of first appearance. Loop the groups, then loop {#items} inside each. | {#items|groupBy:cat}{key}={count} {/items} gives hw=2 sw=2 |
Conditions, for sections
These return true or false, which makes them section tests rather than value formatters: a section over true renders once, and a section over false renders not at all. Pair one with {^…} for the else branch.
| Formatter | Does | Worked example |
|---|---|---|
eq | True when equal (compared as text) | {#status|eq:shipped}Dispatched{/status}[{^status|eq:shipped}not{/}] with "shipped" gives Dispatched[] |
ne | True when not equal | {#status|ne:draft}not a draft{/status} with "shipped" gives not a draft |
gt | True when greater | {#total|gt:1000}Free delivery{/total} with 1240.5 gives Free delivery |
gte | True when greater or equal | {#total|gte:1240.5}…{/total} with 1240.5 renders |
lt | True when less | {#total|lt:1000}small{/total}[{^total|lt:1000}not small{/}] with 1240.5 gives [not small] |
lte | True when less or equal | {#qty|lte:3}three or fewer{/qty} with 2 renders |
contains | Substring of a string, or membership of a list | {#note|contains:urgent}URGENT{/note} with "please treat as urgent", and {#tags|contains:beta}has beta{/tags} with ["alpha","beta"], both render |
empty | True for an empty list, empty text, null or false | {#nothing|empty}nothing here{/nothing} with [] gives nothing here. Note that {#nothing} alone would render zero times — empty is how you say something because the list is empty. |
notEmpty | The inverse of empty | {#tags|notEmpty}has tags{/tags} with two tags gives has tags |
Dates, as conditions and as counts
All six read the value as a date, exactly as date does, and compare it against now in the request's time zone.
| Formatter | Does | Worked example (run on 25 August 2026, UTC) |
|---|---|---|
past | True when the date is before now | {#due|past}OVERDUE{/due} with "2020-01-01" gives OVERDUE |
future | True when the date is after now | {#renews|future}still to come{/renews} with "2030-01-01" renders |
before | True when the date is before the argument | {#issued|before:2026-01-01}issued before 2026{/issued} with "2025-06-15" renders |
after | True when the date is after the argument | {#issued|after:2020-01-01}issued after 2020{/issued} with "2025-06-15" renders |
daysSince | Whole days from the date until now, as a number | {issued|daysSince} with "2025-06-15" gave 436 |
daysUntil | Whole days from now until the date, as a number | {renews|daysUntil} with "2030-01-01" gave 1225 |
Why conditions are formatters rather than an expression language. There is no evaluator here on purpose: nothing you send is ever compiled or executed, which is the entire reason this service does not carry a sandbox with published escape CVEs. The price is that a condition has to be written as a pipeline — {#total|gt:1000} rather than {#if total > 1000} — and that is the trade being made deliberately.
Formatter errors
| Code | When |
|---|---|
unknown_formatter | No formatter by that name. The error lists every one that does exist. |
formatter_type | Wrong kind of value — sum on something that is not an array, number on something that is not a number, date on something unparseable. |
formatter_arg | Missing or nonsensical argument — divide:0, number:99, groupBy with no field. |
sum_missing_field | sum or sumProduct hit an item without the field, naming the item's position. |
Images#
{%logo} places a picture, in Word, Excel and PowerPoint. The value it resolves to may be:
| Value | Meaning |
|---|---|
A base64 string, or a data:image/png;base64,… URI | The image bytes. Sized from the file's own dimensions. |
{"data": "<base64>", "width": 120, "height": 40, "alt": "Logo"} | Bytes plus an explicit size in pixels at 96 DPI. Give one of width/height and the other is derived from the aspect ratio. base64, bytes and content are accepted as aliases for data. |
{"url": "https://…"} | Not fetched. DocMint never makes an outbound HTTP request on your behalf. Supply the bytes for that URL through the request's images object, keyed by the URL or by the tag path, or the render fails with image_url_unsupported. |
Formats: PNG, JPEG, GIF and BMP in Word and Excel; PNG, JPEG and GIF in PowerPoint, which are the formats PowerPoint embeds without conversion. An image may be at most 24 MB.
{
"template": "deck",
"data": { "title": "Q3 review", "logo": { "url": "https://example.com/logo.png" } },
"images": { "https://example.com/logo.png": { "data": "iVBORw0KGgo…" } }
}| Code | When |
|---|---|
image_unresolved | The template places an image but the data has no such key. |
image_invalid / image_bad_data | Not something readable as an image. |
image_not_base64 | A data: URI that is not base64-encoded. |
image_empty | Decoded to zero bytes. |
image_bad_size | A non-numeric width or height. |
image_unsupported_format | The first bytes match no format DocMint can embed. |
image_too_large | Over 24 MB. |
image_url_unsupported | A URL was given and no bytes were supplied for it. |
The missing-field contract#
This is the behaviour the product is judged on. A rendered document must never contain the text undefined, an unresolved {{tag}}, or a silently blank cell where a number was meant to be.
| Situation | Behaviour |
|---|---|
| The key is absent from the data | HTTP 422, naming the field, its location in the document, the field names visible at that point, and a "did you mean" when the name is close to one of them. |
The key is present with the value null | Renders as empty. You explicitly said "nothing here" and were believed. |
The tag is {x|default:—} | The sanctioned opt-out, per tag. |
| Extra keys in the data that no tag uses | Ignored. Only the other direction is an error. |
onMissing
A per-request escape hatch. It is off by default because the failure it prevents is silent and expensive.
| Value | Effect |
|---|---|
"error" | The default. An absent key is a 422. |
"empty" | An absent key renders as an empty string, and a section over an absent key renders zero times. Opt-in relaxation. |
"keep" | Leaves the tag visible in the output, exactly as written. For template debugging only — it is the one setting that puts a literal {{tag}} into a document. |
What a 422 looks like
Verbatim from the live API. A Word template with {#items}{description}, {qty}, {price|currency}{/items} across one table row, rendered with items that have no price:
{
"error": {
"code": "placeholder_unresolved",
"message": "The template uses {price|currency} but the data has no \"price\".",
"hint": "Add \"price\" to the data, or write {price|default:} to allow it to be absent.",
"docs": "https://docmint-832s.onrender.com/docs#errors",
"details": {
"field": "price",
"location": "word/document.xml, table 1 row 2, paragraph 7",
"available": ["invoice_no", "customer", "items", "description", "qty"],
"format": "docx"
},
"request_id": "dm_IHD7M11U8enL"
}
}When the name is close to one that exists, the hint says so. The same template rendered with custmer instead of customer:
{
"error": {
"code": "placeholder_unresolved",
"message": "The template uses {customer} but the data has no \"customer\".",
"hint": "Did you mean \"custmer\"? Otherwise add \"customer\" to the data, or write
{customer|default:} to allow it to be absent.",
"details": {
"field": "customer",
"location": "word/document.xml, paragraph 1",
"available": ["custmer", "total"],
"format": "docx"
}
}
}The location string
It is written for a human holding the file open, not for a parser. All three of these are real strings taken from the live API:
| Format | Example location |
|---|---|
| Word | word/document.xml, paragraph 1word/document.xml, table 1 row 2, paragraph 7 |
| Excel | Invoice!C5 |
| PowerPoint | slide 1, shape "Logo 3"slide 2, table "Items 5", row 2, cell 3slide 1 notes, shape "Notes 1" |
The related codes
| Code | When |
|---|---|
placeholder_unresolved | A value tag whose key is not in the data. |
section_unresolved | A {#name} whose key is not in the data. An empty list [] renders the section zero times and is not an error; an absent key is. |
placeholder_not_scalar | The tag resolves to an object or a list, which cannot be written into the document as text. The hint suggests looping it or reducing it with join or sum. |
placeholder_shadowed | Inside a loop, the tag was found further out and the loop item has a near-miss key of its own. That is a typo, not a deliberate reach outwards, so it fails by name. |
placeholder_outside_loop | Only under strictScope: true. See Warnings. |
section_unbalanced | A {#a} that is never closed, or a {/b} that closes a different section. The message names both markers and where each was opened. |
Warnings#
A warning does not stop the render. It comes back in the JSON response under warnings, and the count is always in the X-DocMint-Warnings response header — including when the response body is the file's bytes rather than JSON.
resolved_from_outer_scope
Scopes nest outwards, so inside {#rows} a tag can reach the document root. That is usually what was wanted — {currency} from the invoice root — and occasionally a serious bug: if the rows have product_name and the template says {name}, and name exists at the root, every row prints the same value and nothing errors. DocMint cannot tell which case it is from the data alone, so it records every occurrence rather than guessing silently in either direction.
"warnings": [
{
"code": "resolved_from_outer_scope",
"field": "currency",
"location": "word/document.xml, table 1 row 2, paragraph 5",
"message": "{currency} is not a field of the loop item; it was taken from 1 level
further out, so it prints the same value on every row.",
"item_fields": ["title", "amount"]
}
]strictScope
Send "strictScope": true and the same situation becomes a 422 instead. Use it once on a new template to prove every loop tag really belongs to its item, then turn it off — or leave it on and write {../currency} where you meant the outer value.
{
"error": {
"code": "placeholder_outside_loop",
"message": "{currency} is not a field of the loop item.",
"hint": "strictScope is on, so a tag inside a loop must name a field of the item.
Write {../currency} if you meant the value from outside the loop.",
"details": {
"field": "currency",
"location": "word/document.xml, table 1 row 2, paragraph 5",
"available": ["title", "amount"],
"format": "docx"
},
"request_id": "dm_UfljbGSKSbI0"
}
}macros_not_preserved
A macro-enabled template (.docm, .xlsm, .pptm) is filled correctly, but the macros are not guaranteed to survive the rewrite and the file is returned with the plain, non-macro content type. The warning says so on every such render.
POST/v1/render#
The main endpoint. Send a template and some data; get the filled file back.
| Field | Type | Notes |
|---|---|---|
template | string | The name (or tpl_… id) of a saved template. Exactly one of this and template_base64. |
template_base64 | string | The template file itself, base64. See Sending the template. |
template_version | integer | Pin a saved template to one version. Only meaningful with template. |
data | object | Your values. See The data object. Defaults to {}. |
output | string | document (the default), pdf or both. See Output modes. |
response | string | "json" to get the file base64-encoded inside a JSON envelope instead of as raw bytes. Sending Accept: application/json does the same. |
filename | string | The name in Content-Disposition and in the JSON envelope. May itself contain placeholders — see below. |
onMissing | string | error (the default), empty or keep. See the missing-field contract. |
strictScope | boolean | Turn the outer-scope warning into an error. |
locale | string | BCP 47, default en-US. See Localisation. |
currency | string | 3-letter ISO code, default USD. |
timezone | string | IANA zone, default UTC. |
images | object | Bytes for image URLs the data refers to. See Images. |
Unknown fields are refused, by name, with a suggestion. Sending fileName, convertTo, context or output_format gets a 400 that tells you the real name. That sounds unfriendly until you have spent an hour working out why {"fileName": "x.docx"} did nothing.
{
"error": {
"code": "unknown_field",
"message": "There is no field called \"convertTo\" - did you mean \"output\"?",
"hint": "Rename \"convertTo\" to \"output\".",
"details": {
"sent": "convertTo",
"meant": "output",
"accepted": ["template","template_base64","template_version","data","output",
"filename","locale","currency","timezone","onMissing",
"strictScope","response","images"]
},
"docs": "https://docmint-832s.onrender.com/docs#render"
}
}Response headers
| Header | Meaning |
|---|---|
X-DocMint-Request-Id | On every response, success or failure. Quote it in a bug report. |
X-DocMint-Credits-Remaining | What is left of this month's allowance after this call. |
X-DocMint-Warnings | How many warnings this render produced. Worth alerting on. |
X-RateLimit-Limit / -Burst / -Remaining | 120 per minute, burst 30, and how many tokens are left. |
The filename
The filename may contain placeholders, so a workflow can produce a per-document name without a separate expression node. It is resolved against the same data, with missing fields treated as empty — a filename is cosmetic and failing the whole render over it would be absurd — and characters that would make the name unusable as a download, including path separators, are stripped. The extension is forced to match what is actually being returned.
curl -X POST https://docmint-832s.onrender.com/v1/render \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d '{"template":"quote","filename":"quote_{customer}.docx",
"data":{"customer":"Acme","total":1240.5}}' \
-D - -o quote.docx
Sending the template#
Exactly one of template and template_base64. Sending both is refused rather than silently preferring one, because that is how a workflow ends up rendering last month's letterhead.
| Approach | When |
|---|---|
Saved — {"template": "invoice"} | Usually. The template is stored once under a name you choose, so replacing the letterhead does not mean editing every workflow that used it. Smaller requests, too. |
Inline — {"template_base64": "UEsDBB…"} | When the template comes from somewhere else in the workflow, or when you want nothing stored on this service at all. Nothing about an inline template is written to the database. |
Base64 with or without a data: prefix is accepted, and whitespace and the URL-safe alphabet are tolerated. From a shell, base64 -w0 invoice.docx.
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_template | Neither field was sent. |
| 400 | ambiguous_template | Both were sent. |
| 400 | version_without_template | template_version alongside template_base64, which has no versions. |
| 400 | bad_base64 | Not valid base64, or it decoded to zero bytes. |
| 413 | template_too_large | Over 25 MB. |
The data object#
A JSON object. Not an array, and not a scalar — if you want to loop over a list, put it under a key and loop with {#items}:
{
"error": {
"code": "bad_data",
"message": "\"data\" must be a JSON object, not an array.",
"hint": "If you want to loop over a list, put it under a key: {\"items\": [...]},
and loop with {#items} in the template.",
"docs": "https://docmint-832s.onrender.com/docs#data"
}
}At most 8 MB, measured as the serialised JSON; over that is 413 data_too_large. Large payloads are almost always base64 images, and an image can be sent through the images object instead.
Output modes#
Chosen per request, not fixed per template in a dashboard. The same stored template can produce a .docx for one workflow and a PDF for another.
output | Response | Credits |
|---|---|---|
document(the default) | The filled Office file's raw bytes, with the format's own content type and a Content-Disposition. | 1 |
pdf | The PDF's raw bytes, application/pdf. The Office file is not returned. | 2 |
both | Always a JSON envelope, with the Office file and the PDF each base64-encoded — two files cannot be two bodies. | 2 |
Add "response": "json" (or an Accept: application/json header) to get the JSON envelope for any mode. It is the only way to see stats and warnings, so it is worth using while building a template.
{
"request_id": "dm_xteafxWKjXav",
"format": "xlsx",
"document": {
"filename": "document.xlsx",
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"size": 6650,
"base64": "UEsDBBQ…"
},
"pdf": {
"filename": "document.pdf",
"content_type": "application/pdf",
"size": 31686,
"pages": 2,
"base64": "JVBERi0…"
},
"stats": {
"tags": 14, "resolved": 14, "sections": 2, "images": 0,
"parts": ["xl/workbook.xml", "xl/worksheets/sheet1.xml", "xl/worksheets/sheet2.xml"],
"ms": 2708.5,
"stages": { "load": 0.3, "quota": 3.2, "fill": 7.2, "pdf": 2694.2 }
},
"warnings": [],
"credits": { "used": 2, "remaining": 18, "limit": 30 }
}stats.stages is where the time actually went, in milliseconds: load is fetching the template, quota the credit reservation, fill the document surgery, pdf the LibreOffice conversion.
PDF output#
PDFs are produced by headless LibreOffice, run as a separate process on the same instance. Word documents go through the Writer filter, workbooks through Calc, decks through Impress.
The PDF step is much slower and much heavier than the fill. Measured on this deployment on 25 August 2026: a fill is single-digit milliseconds; a conversion took 2.69 s and peaks around 219 MB of memory. That is a third of a small container, so conversions run one at a time and queue behind each other. Filling a template is not throttled at all. If you do not need a PDF, do not ask for one.
| Status | Code | Meaning |
|---|---|---|
| 501 | pdf_not_available | LibreOffice is not installed on the instance. GET /healthz reports this before you find out the hard way. |
| 502 | pdf_conversion_failed | LibreOffice ran and produced no PDF, a non-PDF, or a non-zero exit. Ask for the Office file instead and open it; that shows what went wrong. |
| 503 | pdf_queue_full | 24 conversions are already queued on this instance. Retry in a few seconds. |
| 504 | pdf_timeout | Longer than 90 seconds and it was stopped. |
Not supported: PDF/A, watermarking, PDF encryption, page-range selection, and any choice of converter. There is one converter and one profile.
Localisation#
Three per-request settings feed every formatter that has a locale-dependent result: number, currency, percent, date, and the collator that sort uses on strings.
| Field | Default | Notes |
|---|---|---|
locale | en-US | A BCP 47 tag: de-DE, fr-CA, en-GB. Anything the runtime's Intl does not recognise is a 400 rather than a 500. |
currency | USD | A 3-letter ISO code, used by {x|currency} when the tag gives none. |
timezone | UTC | An IANA zone name such as Europe/Berlin. Used by every date formatter, so a timestamp does not silently shift a day. |
| Status | Code | Example message |
|---|---|---|
| 400 | bad_locale | "en_US" is not a language tag Intl recognises. |
| 400 | bad_timezone | "Berlin" is not an IANA time zone. |
| 400 | bad_currency | "Euro" is not a 3-letter ISO currency code. |
ordinal is the one formatter that is not localised: it produces English suffixes whatever the locale says.
POST/v1/inspect#
"What fields does this template need?" — for a file you have not stored. It reads a template the caller already has and produces no document, so it costs no credits; charging for the one call that prevents a bad render would only discourage it. It never throws on a field you have not supplied, because it never looks at data.
Takes template, template_base64 or template_version, exactly as render does.
curl -X POST https://docmint-832s.onrender.com/v1/inspect \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d "{\"template_base64\":\"$(base64 -w0 report.pptx)\"}"{
"request_id": "dm_UHkGNxmKNkIY",
"format": "pptx",
"fields": [
{ "name": "client.name", "scope": "", "type": "string", "repeating": false,
"required": true, "formatters": [], "used": 4,
"locations": ["slide 1, shape \"Subtitle 2\"", "slide 2, shape \"Heading 4\""] },
{ "name": "logo", "scope": "", "type": "image", "repeating": false,
"required": true, "formatters": [], "used": 2,
"locations": ["slide 1, shape \"Logo 3\""] },
{ "name": "rows", "scope": "", "type": "array", "repeating": true,
"required": true, "formatters": ["count", "sum", "currency"], "used": 6,
"locations": ["slide 2, table \"Items 5\", row 1, cell 3",
"slide 2, table \"Items 5\", row 3, cell 2",
"slide 2, table \"Items 5\", row 3, cell 3"] },
{ "name": "amount", "scope": "rows", "type": "number", "repeating": false,
"required": true, "formatters": ["currency"], "used": 1,
"locations": ["slide 2, table \"Items 5\", row 2, cell 3"] }
],
"names": ["title", "subtitle", "client.name", "logo", "notes", "rows",
"sku", "qty", "amount", "findings", "label", "detail", "owner"],
"tags": [
{ "expr": "logo", "kind": "image",
"location": "slide 1, shape \"Logo 3\"" },
{ "expr": "notes", "kind": "value",
"location": "slide 1 notes, shape \"Notes 1\"" },
{ "expr": "rows", "kind": "section",
"location": "slide 2, table \"Items 5\", row 2, cell 1" },
{ "expr": "rows|sum:amount|currency:EUR", "kind": "value",
"location": "slide 2, table \"Items 5\", row 3, cell 3" },
{ "expr": "findings", "kind": "inverted",
"location": "slide 3, shape \"Body 6\"" }
],
"sample_data": {
"title": "title", "subtitle": "subtitle", "client": { "name": "name" },
"logo": null, "notes": "notes",
"rows": [{ "sku": "sku", "qty": "qty", "amount": 0 }],
"findings": [{ "label": "label", "detail": "detail", "owner": "owner" }]
},
"template": null
}How it knows the nesting
Not by parsing the tags a second time. The template is run through the real renderer, with no data and a probe attached to the template context, which records every lookup and the depth of the scope stack it happened at. A section with no data iterates zero times, so the first pass sees only the root; one empty object is then fed into every section that was found and the pass runs again, until nothing new appears. Five passes covers five levels of nesting.
The point of doing it that way is that the answer is, by construction, exactly what a render would ask for: the same scope rules, the same section semantics, the same handling of each format's awkward cases. A second implementation would drift from the first, and the drift would land on you.
The response
| Field | Meaning |
|---|---|
format | docx, xlsx or pptx, decided from the package's own content types rather than from a filename or a claimed MIME type. |
fields | The typed, nested field list. One entry per distinct field; see the table below. |
names | Just the names, flat, in the order they first appear — for when a list of strings is all you want. |
tags | Every tag as it is written in the document, with its expr (including the formatter pipeline), its kind — value, section, inverted, close, image, raw, comment — and its location. Where fields is what to send, tags is what is written. |
sample_data | A correctly nested skeleton you can POST straight back, so your first render succeeds instead of being your first error. |
template | null for an inline file; the id, name and version for a stored one. |
Inside one field
| Key | Meaning |
|---|---|
name | The path, relative to its scope. amount, not rows.amount. |
scope | The thing that makes it a tree. Empty string for the root, otherwise the path of the section this field lives inside. {"name":"qty","scope":"items"} means qty belongs to each element of items. Nest a form on this. |
type | string, number, boolean, date, array or image, inferred from the formatters written on the tag. The first formatter decides, because it is the one that sees the raw value: {total|sum:amount|currency:EUR} needs a list, not a number. Where nothing says otherwise, string. |
repeating | True when the field is a section — a list of rows rather than one value. |
required | False when every place the field is written carries |default:, or when it is only ever an inverted section ({^x}, which is exactly the "may be absent" case). A field written once with a default and once without is required: the stricter reading is the one that stops a document going out with a hole in it. |
formatters | Every formatter applied to this field anywhere in the template, so a form can hint at what shape the value should be in. |
used | How many times the field is read. |
locations | Where it appears, up to eight places. |
inferred | True when the field is never written in the document but a formatter elsewhere proves every row must carry it. A template whose only mention of the line items is {items|sumProduct:qty:price|currency:EUR} reports qty and price as fields of items, typed number, with "used": 0, empty locations and "inferred": true — and puts them in sample_data, because a render without them fails. |
The sample renders. Verified against the live API on 25 August 2026: POST /v1/inspect for a template with a line-item loop returned {"invoice_no": "invoice no", "customer": "customer", "items": [{"description": "description", "qty": 0, "price": 0}]}, and posting exactly that back to POST /v1/render produced a 1,084-byte document with zero warnings. An image field comes back as null, because DocMint cannot invent a picture; supply one before rendering.
Template management#
Templates are addressed by a name you choose, not by an opaque id. A workflow that says template: "invoice" keeps working when the finance team uploads a new letterhead. The tpl_… id exists too and works anywhere a name does.
Names are lower case letters, digits, dot, dash and underscore, 1 to 64 characters, starting and ending with a letter or digit. A name is lower-cased for you.
POST/v1/templates PUT/v1/templates/:name
Creates a template, or adds a version to one that exists. POST takes the name in the body; PUT takes it in the path.
| Field | Type | Notes |
|---|---|---|
name | string | Required on POST, not accepted on PUT. |
file_base64 | string | Required. The .docx, .xlsx or .pptx file. |
description | string | Optional, on the template. Updating it does not create a version. |
note | string | Optional, on this version — "new VAT rate", "logo swap". Shows up in the version list. |
The response is 201 for a new version and 200 with "unchanged": true when the bytes are identical to the current version. Re-uploading the same file does not burn a version number, because a workflow that syncs a template on every run should not fill the history with copies of one file.
{
"request_id": "dm_ttYFcKAbX-Fn",
"id": "tpl_SGmNyoFTTGVf",
"name": "lines",
"format": "docx",
"version": 1,
"unchanged": false,
"size": 1129,
"fields": [ /* the same typed descriptors as GET …/fields, see below */ ],
"macro_enabled": false
}The file is identified and the fields extracted at upload time, so a file that cannot be filled is refused while you are looking at the response rather than at 3 a.m. when a workflow runs.
GET/v1/templates
Every template on the account, with its current version, size, SHA-256 and field list.
GET/v1/templates/:name
One template in full, including versions.
GET/v1/templates/:name/fields#
The endpoint this product is built around. Same response as inspect — fields, names, tags, sample_data — for a stored template. Free. Accepts ?version=N.
The fields are re-derived from the stored bytes on every call rather than served from what was recorded at upload, so a template uploaded before a renderer improvement still reports accurately.
curl -H "Authorization: Bearer dm_live_xxx" \
"https://docmint-832s.onrender.com/v1/templates/lines/fields"{
"id": "tpl_SGmNyoFTTGVf",
"name": "lines",
"format": "docx",
"version": 1,
"fields": [
{ "name": "invoice_no", "scope": "", "type": "string", "repeating": false,
"required": true, "formatters": [], "used": 2,
"locations": ["word/document.xml, paragraph 1"] },
{ "name": "customer", "scope": "", "type": "string", "repeating": false,
"required": true, "formatters": [], "used": 2,
"locations": ["word/document.xml, paragraph 1"] },
{ "name": "items", "scope": "", "type": "array", "repeating": true,
"required": true, "formatters": ["sumProduct", "currency"], "used": 4,
"locations": ["word/document.xml, table 1 row 2",
"word/document.xml, paragraph 8"] },
{ "name": "description", "scope": "items", "type": "string", "repeating": false,
"required": true, "formatters": [], "used": 1,
"locations": ["word/document.xml, table 1 row 2, paragraph 5"] },
{ "name": "qty", "scope": "items", "type": "number", "repeating": false,
"required": true, "formatters": [], "used": 1,
"locations": ["word/document.xml, table 1 row 2, paragraph 6"] },
{ "name": "price", "scope": "items", "type": "number", "repeating": false,
"required": true, "formatters": ["currency"], "used": 1,
"locations": ["word/document.xml, table 1 row 2, paragraph 7"] }
],
"names": ["invoice_no", "customer", "items", "description", "qty", "price"],
"tags": [ … ],
"sample_data": {
"invoice_no": "invoice no",
"customer": "customer",
"items": [{ "description": "description", "qty": 0, "price": 0 }]
}
}Three fields live at the root and three inside items, and the response says so with scope rather than leaving you to infer it from a flat list of six names. That is what a form builder — or n8n's resourceMapper — needs in order to render a repeating section instead of a JSON textarea.
GET/v1/templates/:name/file
The stored bytes back, with the right content type and a Content-Disposition. Accepts ?version=N. Free — downloading a file you already uploaded is not metered.
DELETE/v1/templates/:name
Deletes the template and every version of it. There is no undo.
{ "deleted": true, "id": "tpl_SGmNyoFTTGVf", "name": "lines" }Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_file | No file_base64. |
| 400 | missing_template_name / bad_template_name | No name, or one that breaks the rules above. |
| 404 | template_not_found | The hint lists the templates you actually have — up to twelve of them, most recently updated first. |
| 409 | template_format_changed | Uploading an .xlsx over a name that holds a .docx. Changing a template's format would break every workflow using it, so it is refused; upload under a different name or delete the old one first. |
| 415 | template_is_pdf, template_is_legacy_office, template_is_opendocument, template_not_office, template_unknown_office_part | The file is recognised as something else and named as such — a PDF, an old binary .doc/.xls/.ppt, an OpenDocument file, a non-zip, or an Office package that is none of the three kinds DocMint fills. |
{
"error": {
"code": "template_not_found",
"message": "You have no template called \"nope\".",
"hint": "Templates on this account: report (docx), lines (docx), quote (docx),
invoice (docx).",
"details": { "available": ["report", "lines", "quote", "invoice"] },
"docs": "https://docmint-832s.onrender.com/docs#templates"
}
}Versions and rollback#
Every upload is a new version rather than an overwrite. A template is a thing a business depends on, and discovering an hour after replacing one that the old one was better has to be recoverable. The most recent 20 versions are kept; older ones are pruned oldest-first and never below one.
curl -H "Authorization: Bearer dm_live_xxx" \
https://docmint-832s.onrender.com/v1/templates/quote/versions{
"id": "tpl_6G2UxfHtsmUu",
"name": "quote",
"current": 2,
"versions": [
{ "version": 2, "size": 1035,
"sha256": "b9902f378a8b4922c20ea48cef9087ef3cab315eba53bbd70aaab46884b9128e",
"note": "added a validity date", "fields": 3,
"created_at": "2026-08-25T08:54:04.109Z" },
{ "version": 1, "size": 1001,
"sha256": "4efcfcbf0ff3a2f945fb3619bb5ad0a61183b7d7e37ba39f55221d2bca9d59d2",
"note": null, "fields": 2,
"created_at": "2026-08-25T08:51:07.079Z" }
]
}Pin a render to one version with "template_version": 2. Roll the whole template back with:
curl -X POST https://docmint-832s.onrender.com/v1/templates/quote/rollback \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d '{"version":1}'{ "request_id": "dm_FdTo_2gvw4ko", "id": "tpl_6G2UxfHtsmUu",
"name": "quote", "version": 3, "restored_from": 1 }Rolling back copies the old version forward as a new one rather than deleting the versions after it. Nothing is lost, and a rollback made in a panic can itself be rolled back.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_version | Not a whole number of 1 or more. |
| 400 | missing_version | A rollback with no version. |
| 404 | template_version_not_found | That version is gone or never existed. The hint lists the versions still stored. |
GET/v1/usage#
This calendar month's credits and a breakdown of what produced them, grouped by kind, format, output mode and success.
{
"plan": { "id": "free", "name": "Free", "price_usd": 0 },
"credits": { "used": 12, "limit": 30, "remaining": 18 },
"period_start": "2026-08-01T00:00:00.000Z",
"breakdown": [
{ "kind": "render", "format": null, "output": "document", "ok": false,
"n": 9, "credits": 0, "avg_ms": 14 },
{ "kind": "render", "format": "docx", "output": "document", "ok": true,
"n": 7, "credits": 7, "avg_ms": 20 },
{ "kind": "render", "format": "docx", "output": "pdf", "ok": true,
"n": 1, "credits": 2, "avg_ms": 3381 },
{ "kind": "render", "format": "pptx", "output": "document", "ok": true,
"n": 1, "credits": 1, "avg_ms": 22 },
{ "kind": "render", "format": "xlsx", "output": "both", "ok": true,
"n": 1, "credits": 2, "avg_ms": 2705 }
]
}Note the first row: nine failed renders, zero credits. A usage record holds what kind of call it was, the format, the output mode, the duration, the stage timings, whether it succeeded and the error code if not. It never holds document content or the request body.
GET/v1/capabilities#
No key needed. What this build can actually do, published so that the documentation and any client can be checked against the running code rather than against a README that drifted. The formatter list is read out of the module, so a formatter cannot be documented into existence — and the list on this page is checked against this endpoint, in both directions, before the page ships.
curl https://docmint-832s.onrender.com/v1/capabilities{
"formats": [
{ "id": "docx", "name": "Word document", "mime": "application/vnd.openxml…" },
{ "id": "xlsx", "name": "Excel workbook", "mime": "application/vnd.openxml…" },
{ "id": "pptx", "name": "PowerPoint deck", "mime": "application/vnd.openxml…" }
],
"outputs": ["document", "pdf", "both"],
"pdf": { "available": true, "engine": "libreoffice", "concurrency": 1 },
"formatters": [
{ "name": "add", "does": "add:5" },
{ "name": "count", "does": "how many items" },
{ "name": "currency", "does": "currency:EUR gives EUR 1,234.57" }
],
"limits": {
"max_template_bytes": 26214400,
"max_data_bytes": 8388608,
"max_versions_kept": 20,
"pdf_timeout_ms": 90000
},
"credits": { "document": 1, "pdf": 2, "both": 2 }
}GET/healthz#
No key needed. Reports whether LibreOffice is actually present — an image built without it looks perfectly healthy right up until the first request that asks for a PDF — and how many conversions are running and queued. Add ?db=1 to time two trivial database round trips and see the connection pool's state.
{
"ok": true,
"pdf": { "available": true, "active": 0, "queued": 0, "limit": 1 },
"db": { "first_ms": 3.4, "second_ms": 2.5,
"pool": { "total": 1, "idle": 1, "waiting": 0 }, "error": null }
}Not available yet#
Two things are being built and are not reachable on this deployment. They have sections here because the code that will emit them already links to these anchors, and a documentation link in an error message that 404s is worse than no link at all.
Every one of these answered 404 unknown_endpoint when this page was written, on 25 August 2026: POST /v1/jobs, GET /v1/jobs, GET /v1/jobs/:id, POST /v1/jobs/:id/cancel, POST /v1/render/batch, GET /v1/webhooks. Nothing about their behaviour is described here, because nothing about their behaviour has been observed.
Asynchronous rendering#
Not available yet. Every render today is synchronous: you POST, and the response body is the file. That is the right shape for one document and the wrong shape for five hundred, so a queued mode with a webhook callback is in progress.
Until it ships: render one document per request. A PDF takes about 2.7 s and conversions are serialised per instance, so pace a large run rather than firing it all at once — the rate limit is 120 requests a minute with a burst of 30, and a full conversion queue answers 503 pdf_queue_full rather than dropping the request silently.
When it ships, this section is replaced by its reference. If you have arrived here from an error message naming /docs#async and this is still the text you see, the feature is newer than this page — please report it, because that is exactly the drift this page is built to avoid.
Batch rendering#
Not available yet. One template, many data rows, one call. Same status as above, and the same advice: loop over POST /v1/render in your workflow tool for now, which is what an n8n or Make loop does naturally, and which keeps a failure attributable to a single row.
Formats, and what each one cannot do#
Three formats, one syntax. The file is identified from the main part's content type in [Content_Types].xml, not from its extension or the MIME type a client claimed — both are routinely wrong, and the failure they cause is a stack trace inside a zip parser rather than a sentence you can act on.
| Format | Extensions accepted | Content type returned |
|---|---|---|
| Word | .docx .dotx .docm | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| Excel | .xlsx .xltx .xlsm | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| PowerPoint | .pptx .potx .ppsx .pptm | application/vnd.openxmlformats-officedocument.presentationml.presentation |
Macro-enabled templates are filled, but their macros are not guaranteed to survive the rewrite, and the result comes back with the plain content type above. The render response carries a macros_not_preserved warning saying exactly that.
Word — what is not implemented
- No HTML or Markdown into a document. A value goes in as text, inheriting the placeholder's own formatting. For anything richer there is
{@rawXml}, which is raw WordprocessingML and entirely your responsibility. - No charts, no styling API, no footnote or subtemplate features. Whatever style the placeholder carries is what the value inherits, and that is the whole styling story.
- Images are PNG, JPEG, GIF or BMP, up to 24 MB each.
Excel — what is not implemented, and where it is subtle
- Cell types matter, and are handled deliberately. A cell whose entire content is one placeholder becomes: a real numeric cell if the value is a JS number or the last formatter is arithmetic (
sum,sumProduct,count,round,multiply,add,subtract,divide); a boolean cell for a boolean; an Excel date serial for a date value in a cell that already has a date number format; and an inline string for everything else. The cell keeps itss=style, so its number format, font, border and fill survive. - A numeric string stays text on purpose. Turning
"007"into7would quietly ruin part numbers and postcodes. Write{n|add:0}if you want it coerced. currency,number,percentanddateproduce text, on purpose — you asked for that exact string. To get a real number that still displays as money, format the cell in the template and write a bare{total}: the cell's own number format does the work andSUM()over the column still adds up.- No column loops and no sheet loops. A section whose two tags sit in one row is a row loop, because a loop that repeated part of a row would leave the rest of the line behind.
- Formula rewriting has known edges. Row insertion semantics are followed, so
SUM(D5:D6)over a loop body becomesSUM(D5:D10)when the loop produced six rows. But a range whose two ends are the same repeated row —SUM(B3:B3)over a per-group subtotal — widens to first..last occurrence and therefore covers the rows in between as well, so a grand total is better written as a sum of the detail rows than as a sum of the subtotals. Chart series and pivot caches keep their own copies of the ranges they read and are not rewritten. - The shared string table is never edited: a cell containing a tag is rewritten as an inline string instead. Orphaned shared entries are left behind, which Excel ignores.
- A sheet that would need more than 1,048,576 rows fails with
too_many_rows.
PowerPoint — what is not implemented
- Images are PNG, JPEG or GIF only — the formats PowerPoint embeds without conversion. A BMP that is fine in Word is refused here.
- Section markers must open and close inside the same text box; a
{#a}in one shape and its{/a}in another issection_unbalanced. - Notes slides are filled too, and report locations as
slide N notes, shape "…". - The finished package is re-checked against the invariants PowerPoint enforces — slide list, relationships, content-type overrides, slide id ranges — before the bytes are returned, because PowerPoint refuses the whole file if any one of them is inconsistent and says only "PowerPoint found a problem with content". A failure here is
package_invariantand is a bug worth reporting.
Not accepted at all
| File | Code | What to do |
|---|---|---|
| A PDF | template_is_pdf | A PDF cannot be a template here. Building a PDF from HTML is a different product. |
.doc, .xls, .ppt | template_is_legacy_office | Open it in Office and "Save As" the modern format. |
.odt, .ods, .odp | template_is_opendocument | In LibreOffice, "Save As" and choose the Word/Excel/PowerPoint 2007-365 format. |
| Anything not a zip | template_not_office | Check the base64 decoded correctly — the first two bytes should be PK. |
| A zip that is not Office | template_unknown_office_part | It has no recognised main part. |
| A truncated or damaged file | template_corrupt | Re-save it from Office. A file that went through an email gateway or a text-mode transfer is often truncated. |
Every error code#
Every error has the same shape. code is stable and safe to switch on; message is one line for a human; hint says what to change; docs links to the section of this page that explains it; details carries the machine-readable specifics, and request_id identifies the exact log line.
{
"error": {
"code": "placeholder_unresolved",
"message": "…", "hint": "…",
"docs": "https://docmint-832s.onrender.com/docs#errors",
"details": { "field": "…", "location": "…", "available": ["…"], "format": "docx" },
"request_id": "dm_…"
}
}Why 422 and not 400 for a missing field. The request was well-formed and understood; it just cannot be carried out, because the template asks for something the data does not have. A caller can therefore tell "I sent nonsense" from "my data is missing a field" without parsing the message.
400 — the request is wrong
| Code | Meaning |
|---|---|
bad_json | The body is not valid JSON. |
unknown_field | A field this endpoint does not accept, with the closest real name. |
bad_option | A field whose value is not one of the allowed ones — output, onMissing. |
bad_data | data is not a JSON object. |
bad_base64 | Not valid base64, or zero bytes after decoding. |
missing_template | Neither template nor template_base64. |
ambiguous_template | Both of them. |
version_without_template | template_version with an inline template. |
missing_file | A template upload with no file_base64. |
missing_template_name | A template upload with no name. |
bad_template_name | A name that breaks the naming rules. |
bad_version | A version that is not a whole number of 1 or more. |
missing_version | A rollback with no version. |
bad_locale bad_timezone bad_currency | See Localisation. |
filename_too_long | The filename pattern is longer than 200 characters. |
empty_template | The template file is empty. |
template_corrupt | It looks like a zip but could not be read. |
template_too_large | Over 25 MB on upload. (The same condition on a render is a 413.) |
bad_email weak_password | See Sign up. |
401, 402, 404, 409 — who you are, what you have
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No key was sent. |
| 401 | invalid_api_key | Not a DocMint key, or revoked. |
| 402 | quota_exceeded | This month's documents are used up. See Quota. |
| 402 | plan_required | The account's allowance is zero, so it never had a quota to exceed — a different statement, deliberately. |
| 404 | template_not_found | The hint lists the templates you do have. |
| 404 | template_version_not_found | The hint lists the versions still stored. |
| 404 | key_not_found | No live key starts with that prefix. |
| 404 | unknown_endpoint | No such /v1/… route. |
| 409 | email_taken | An account with that address exists. |
| 409 | last_key | Refusing to revoke the only key. |
| 409 | template_format_changed | A different format under an existing name. |
413, 415 — too big, or the wrong kind of file
| Status | Code | Meaning |
|---|---|---|
| 413 | request_too_large | The whole body is over 36 MB. A base64 template is a third larger than the file; upload it once and reference it by name instead. |
| 413 | template_too_large | The template alone is over 25 MB. |
| 413 | data_too_large | data is over 8 MB. |
| 415 | format_unsupported | Recognised, but not a format DocMint fills. |
| 415 | template_is_pdf template_is_legacy_office template_is_opendocument template_not_office template_unknown_office_part | See Formats. |
422 — the template and the data disagree
Everything here comes back with details.field, details.location and usually details.available. A 422 costs no credits.
| Code | Meaning |
|---|---|
placeholder_unresolved | A value tag whose key is not in the data. |
section_unresolved | A {#name} whose key is not in the data. |
placeholder_not_scalar | The tag resolves to an object or a list. |
placeholder_shadowed | An outer-scope hit inside a loop where the item has a near-miss key of its own. |
placeholder_outside_loop | Any outer-scope hit inside a loop, under strictScope: true. |
section_unbalanced | Section markers that do not pair up. The message names both and where each was opened. |
section_in_shape | A section marker somewhere a section cannot be expanded. |
unknown_formatter | No such formatter. The error lists every one that exists. |
formatter_type | A formatter got the wrong kind of value. |
formatter_arg | A formatter got a missing or nonsensical argument. |
sum_missing_field | sum or sumProduct hit an item without the field, naming which item. |
bad_option | onMissing reached the renderer with a value it does not know. |
image_unresolved | An image tag with no matching key in the data. |
image_invalid image_bad_data | Not readable as an image. |
image_not_base64 | A data: URI that is not base64-encoded. |
image_empty | Decoded to zero bytes. |
image_bad_size | A non-numeric width or height. |
image_unsupported_format | Not a format this renderer embeds. |
image_too_large | Over 24 MB. |
image_url_unsupported | A URL with no bytes supplied for it. DocMint never fetches. |
too_many_rows | A worksheet would need more than 1,048,576 rows. |
not_xlsx not_a_pptx | The package is missing the part that makes it a workbook or a presentation. |
package_invariant | The finished PowerPoint package failed its own self-check and was not returned. This is a bug; please report it with the template. |
429, 5xx
| Status | Code | Meaning |
|---|---|---|
| 429 | rate_limited | Over 120 requests a minute on this account. Retry-After says how long to wait. |
| 429 | signup_rate_limited | More than one signup a minute from one address. |
| 500 | render_failed | Filling threw something unexpected. If the template opens correctly in Office this is a bug worth reporting with the file attached. |
| 500 | internal_error | Something else went wrong inside DocMint. Report it with the request id. |
| 501 | pdf_not_available | No LibreOffice on this instance. |
| 501 | format_not_available | This build has no renderer for that format — a deployment fault, not your request. |
| 502 | pdf_conversion_failed | LibreOffice ran and produced no usable PDF. |
| 503 | pdf_queue_full | The conversion queue on this instance is full. |
| 504 | pdf_timeout | Conversion took longer than 90 seconds. |
Three further codes belong to the Stripe endpoints and are listed with them: unknown_plan (400), no_subscription (400) and billing_unavailable (503). See Plans, pricing and billing.
Treat both lists as open. New error codes and new warning codes may be added; a client should switch on the codes it knows and fall back on the HTTP status and the message for the rest, rather than failing on an unfamiliar one.
Limits#
The same for every plan. There is no feature ladder. These numbers are served by GET /v1/capabilities, so they can be checked rather than trusted.
| Limit | Value | Exceeded |
|---|---|---|
| Template file | 25 MB | template_too_large |
data, as serialised JSON | 8 MB | data_too_large |
| Whole request body | 36 MB | request_too_large |
| One image | 24 MB | image_too_large |
| Versions kept per template | 20 | The oldest is pruned, never below one. |
| Rows in one worksheet | 1,048,576 | too_many_rows |
| Filename pattern | 200 characters | filename_too_long |
| PDF conversion time | 90 s | pdf_timeout |
| Concurrent PDF conversions | 1 per instance | Further conversions queue. |
| Queued PDF conversions | 24 | pdf_queue_full |
| Requests per minute, per account | 120 | rate_limited |
| Instantaneous burst | 30 | rate_limited |
Rate-limit state comes back on every authenticated response as X-RateLimit-Limit, X-RateLimit-Burst and X-RateLimit-Remaining; a 429 also carries Retry-After. The bucket lives in the instance's memory, which is the honest implementation while there is one instance.
Quota and credits#
One credit is one document produced.
| Call | Credits |
|---|---|
POST /v1/render with output: "document" (the default) | 1 |
POST /v1/render with output: "pdf" | 2 |
POST /v1/render with output: "both" | 2 |
| A render that fails, for any reason | 0 |
POST /v1/inspect, GET …/fields, GET …/file, uploads, versions, rollback, GET /v1/usage, GET /v1/capabilities | 0 |
The PDF costs one extra because it costs about a hundred times the CPU of the fill — roughly 2.7 s against roughly 10 ms — and pretending the two are the same price would mean the cheap path subsidising the expensive one. Nothing else is metered. Downloading a file you already generated is free, and so is asking a template what it needs.
A failed render costs nothing. The credit is reserved before the work and refunded when the work throws, so a 422 on a missing field, a 502 from LibreOffice or a 504 timeout all leave your balance where it was.
The month
Quotas are per calendar month, in UTC, and reset on the 1st. The window rolls forward lazily on your first request of the new month, so there is no cron job that can fail to run. GET /v1/usage reports period_start.
Subscribing mid-month does not hand you a second quota. The window is the calendar month whatever day you subscribed on, and a renewal only resets the counter if the calendar month has actually turned over.
At the ceiling
{
"error": {
"code": "quota_exceeded",
"message": "You have used all 30 documents included in your free plan this month.",
"hint": "The quota resets on the 1st of next month. To raise it now, upgrade …",
"details": { "plan": "free", "credits_used": 30, "credits_limit": 30 },
"docs": "https://docmint-832s.onrender.com/docs#quota"
}
}There is no overage billing and no surprise invoice: the API answers 402 until the next period or until the plan changes.
Plans, pricing and billing#
| Plan | Price | Documents / month | Per document |
|---|---|---|---|
| Free | $0 | 30 | — |
| Starter | $9 | 2,000 | $0.0045 |
| Pro | $29 | 20,000 | $0.00145 |
| Scale | $99 | 100,000 | $0.00099 |
Every plan has every feature: all three formats, PDF output, images, the typed field list, the missing-field contract, the outer-scope warning, versioning and rollback, localisation, and the same limits. There is no paid module and no per-feature licence, now or ever — the fill engine is written for this project and depends on nothing that could start charging for a feature later.
GET/v1/billing/plans
No key needed. The plan list as the running code has it, so a page cannot show three plans while the API knows about four. A plan with no price configured is listed and marked "purchasable": false rather than hidden.
{
"billing_available": true,
"plans": [
{ "id": "free", "name": "Free", "price_usd": 0, "documents_per_month": 30, "purchasable": false },
{ "id": "starter", "name": "Starter", "price_usd": 9, "documents_per_month": 2000, "purchasable": true },
{ "id": "pro", "name": "Pro", "price_usd": 29, "documents_per_month": 20000, "purchasable": true },
{ "id": "scale", "name": "Scale", "price_usd": 99, "documents_per_month": 100000, "purchasable": true }
],
"note": "One document costs one credit. Asking for a PDF costs one more, because
converting it costs about a hundred times the CPU. Downloading a file you
already made is free."
}POST/v1/billing/checkout
Starts a subscription. Send {"plan": "starter"}; you get back a Stripe Checkout URL to open in a browser. Card details never touch this service.
curl -X POST https://docmint-832s.onrender.com/v1/billing/checkout \
-H "Authorization: Bearer dm_live_xxx" \
-H "Content-Type: application/json" \
-d '{"plan":"pro"}'{ "url": "https://checkout.stripe.com/c/pay/cs_live_…",
"plan": "pro",
"expires_at": 1787735655 }The session collects a billing address and offers an optional VAT ID, which Stripe then puts on the invoice — an EU business needs it there or its accountant will not accept the receipt. Promotion codes are accepted. Because there is no dashboard yet, Stripe returns you to /docs#quota afterwards.
POST/v1/billing/portal
Returns a Stripe customer-portal URL for changing the plan, updating the card, downloading invoices, or cancelling. Takes no body.
{ "url": "https://billing.stripe.com/p/session?secret=…" }Cancelling stops the next charge; it does not refund the current month. There is no overage billing at any point.
| Status | Code | Meaning |
|---|---|---|
| 400 | unknown_plan | No purchasable plan by that name. The hint lists the ones there are. |
| 400 | no_subscription | A portal session for an account that has never had one. Start with checkout; the portal only exists once there is something to manage. |
| 503 | billing_unavailable | Billing is not configured on the deployment you are talking to. GET /v1/billing/plans says so up front as "billing_available": false. |
Changing plan changes your credits_limit immediately; the credits you have already used this month stay used. A subscription ending returns the account to the Free plan's 30 documents a month rather than to zero.