Developers
One request. One document.
The DocMake API turns a template and a JSON object into a finished DOCX or PDF. Authenticate with a bearer
token, then make a document (POST https://app.docmake.io/api/v1/render) out of a template id and the values that fill in the blanks (the data payload): the response body is the file. Every failure answers in one error envelope, and a document that
rendered with something missing says so in a response header instead of quietly shipping a gap. Template
CRUD, DOCX import, batch jobs, assets and usage are on every plan, the free one included.
Quick start
Three steps, about a minute. The request and the response below were run against production and pasted back unedited, with the key redacted.
- 01
Create a key in Settings → API Keys. Keys start with
dm_and are shown once; only a hash is stored. A key belongs to one workspace and can only see that workspace. - 02
Open a template in the editor and copy its id from the URL, or call
GET /api/v1/templates. Ids look liketmpl_ow4Jl7QC7tEw. - 03
POST the id and your data to
/api/v1/render. The body of the response is the file. Set"format": "docx"for Word, and"filename"to name the download.
curl -sS -D - -X POST https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_xxx" \
-H "Content-Type: application/json" \
--output quote.pdf \
-d '{
"template_id": "tmpl_ow4Jl7QC7tEw",
"format": "pdf",
"data": {
"date": "2026-09-02",
"client": {
"name": "Northwind Trading SRL",
"address": "Str. Exemplu 10, Bucuresti",
"cui": "RO12345678",
"contact": "Maria Ionescu"
},
"items": [
{ "product": "Consultanta ISO 9001", "qty": 1, "unit_price": 2500 },
{ "product": "Audit intern", "qty": 2, "unit_price": 800 },
{ "product": "Instruire personal", "qty": 3, "unit_price": 350 }
],
"discount_percent": 10,
"total": 4635,
"signer": { "name": "Elena Marin", "title": "Director" }
}
}'HTTP/2 200
server: nginx
content-type: application/pdf
content-length: 70104
x-docmake-render-time-ms: 1669
x-docmake-warnings: []
cache-control: public
date: Thu, 03 Sep 2026 05:47:44 GMT
last-modified: Thu, 03 Sep 2026 05:47:44 GMT
content-disposition: attachment; filename=oferta.pdf
accept-ranges: none
x-ratelimit-limit: 30
x-ratelimit-remaining: 29
x-ratelimit-reset: 1788414524
access-control-allow-origin: *
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
x-content-type-options: nosniff70 KB of PDF followed the headers into quote.pdf. x-docmake-warnings is an empty array: nothing was missing.
Errors
Every failure under /api answers with the same JSON body, whatever the Accept header says. There is no HTML error page to parse and no
raw framework message to guess at.
{
"error": {
"type": "not_found",
"message": "No template with id tmpl_doesnotexist in this workspace. Check the id, and that the API key belongs to the team that owns it.",
"details": []
}
} type is the stable machine-readable name to branch on.
message is a sentence written for a person.
details is an array that is empty unless the error has per-field or per-value
specifics. Two error shapes add a key: a failed render adds
engine_errors, and a refused strict render adds
missing and details entries with a
path.
Every type
| type | Status | When |
|---|---|---|
| validation_error | 422 | A field in the request body fails a rule. details lists one entry per field and message. |
| missing_data | 422 | A strict render found a blank with no value. missing and details name every one by path. |
| type_mismatch | 422 | A strict render found values of the wrong type and none missing. Same body, missing is empty. |
| authentication_error | 401 | The Authorization header is absent, is not a dm_ key, or the key is unknown or revoked. |
| authorization_error | 403 | The key resolves to a workspace the request may not act on. |
| not_found | 404 | No template, job or asset with that id in this workspace, or the route does not exist. |
| method_not_allowed | 405 | The route exists but not for that HTTP method. |
| bad_request | 400 | A malformed request the router rejects before a controller sees it. |
| render_error | 400 | The document itself is wrong: a broken calculation, an invalid node. engine_errors names each one. |
| conflict | 409 | The request contradicts the current state of the resource. |
| job_not_completed | 409 | A batch download was asked for while the job is still processing. |
| download_expired | 404 | The batch finished but its files are past the download window. |
| no_output | 404 | The batch finished and no item produced a file. |
| payload_too_large | 413 | The request body is over the server limit. |
| token_mismatch | 419 | A session token expired. Only reachable from a browser session, never from a key. |
| rate_limit_error | 429 | The per-minute budget for the workspace is spent. Retry-After says how long to wait. |
| quota_exceeded | 429 | A plan quota is spent, for example the monthly document allowance. Its body has its own shape, see below. |
| server_error | 500 | An unhandled failure. The message is generic and carries no stack trace. |
| render_failed | 500 | The renderer failed on a document that is otherwise valid. Safe to retry. |
| render_busy | 503 | Rendering capacity is saturated. Retry-After: 5. |
| unavailable | 503 | The service is down for maintenance. |
| request_error | other 4xx | A client error with a status not in this table, for example 410. The catch-all name so error.type is always a string you can branch on. |
Worked examples
Each pair below is a real call and its real answer.
401 authentication_error
curl -sS -X POST https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_not_a_real_key" \
-H "Content-Type: application/json" \
-d '{"template_id":"tmpl_ow4Jl7QC7tEw","data":{}}'{
"error": {
"type": "authentication_error",
"message": "Invalid API key. Check that your key is correct and not revoked."
}
}With no Authorization header at all the message is "Invalid or missing API key." Both are 401. These two are written by the authentication middleware and are the only errors without a details key.
404 not_found
curl -sS -X POST https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_xxx" \
-H "Content-Type: application/json" \
-d '{"template_id":"tmpl_doesnotexist","data":{}}'{
"error": {
"type": "not_found",
"message": "No template with id tmpl_doesnotexist in this workspace. Check the id, and that the API key belongs to the team that owns it.",
"details": []
}
}A template that belongs to another workspace answers exactly the same way, so the API never confirms that someone else’s template exists.
422 validation_error
curl -sS -X POST https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_xxx" \
-H "Content-Type: application/json" \
-d '{"format":"xlsx"}'{
"error": {
"type": "validation_error",
"message": "The template id field is required. (and 2 more errors)",
"details": [
{
"field": "template_id",
"message": "The template id field is required."
},
{
"field": "data",
"message": "The data field must be present."
},
{
"field": "format",
"message": "The selected format is invalid."
}
]
}
}details carries one entry per field and message, in rule order. data must be present even when it is empty.
405 method_not_allowed
curl -sS -X GET https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_xxx"{
"error": {
"type": "method_not_allowed",
"message": "The GET method is not supported for route api/v1/render. Supported methods: POST.",
"details": []
}
}An unknown path under /api is a 404 in the same envelope: "The route api/v1/nope could not be found."
400 render_error
curl -sS -X POST https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_xxx" \
-H "Content-Type: application/json" \
-d '{"template_id":"tmpl_xxx","format":"pdf","data":{ ... }}'{
"error": {
"type": "render_error",
"message": "Template rendering failed.",
"engine_errors": [
{
"node_id": "f_line",
"node_kind": "formula",
"node_label": "Line total",
"type": "invalid_formula",
"message": "Multiply by what?",
"severity": "error",
"path": null
}
]
}
}The document is wrong, not the data: this template had a calculation reading qty * with nothing after the operator. engine_errors uses the same fields as a warning, with severity "error". Nothing is rendered.
429 rate_limit_error
for i in $(seq 1 32); do
curl -sS -o /dev/null -w "%{http_code}\n" \
https://app.docmake.io/api/v1/usage \
-H "Authorization: Bearer dm_xxx"
doneHTTP/2 429
x-ratelimit-limit: 30
x-ratelimit-remaining: 0
x-ratelimit-reset: 1788414728
retry-after: 8
{
"error": {
"type": "rate_limit_error",
"message": "Rate limit of 30 requests per minute exceeded. Retry in 8s."
}
}The budget is per workspace, per minute, and every response carries the three X-RateLimit headers so you can pace yourself before you hit it.
The one exception: quota
A spent plan allowance answers 429 with a flat body rather than the envelope, so
error is a string and not an object.
Branch on that before you read error.type.
- error
- The literal string "quota_exceeded", not an object.
- feature
- Which allowance ran out: doc_render, template_create, api_call, ai.
- reason_code
- Why, for example monthly_limit_exceeded or daily_rate_limit.
- message
- A sentence naming the number used, the limit and the plan.
- upgrade_cta
- What the next plan up allows, or null on the top plan.
- used
- How much of the allowance is spent.
- limit
- The allowance for the current plan.
Warnings: a gap is never silent
A blank (a variable in the API) that has
no value in your payload renders as nothing at all. The document still comes back, and the response carries
X-DocMake-Warnings: a JSON array naming
everything that was missing or mistyped. An empty array means a clean render.
Every entry names the node it came from: a blank, an Only when part (a conditional node), a One per item part (a loop
node), a calculation, an image or the document itself. The things to check before you send are in two places:
this header, and POST /api/v1/templates/{id}/validate, which reports the same list without making a document.
x-docmake-warnings: [{"node_id":"dc8fc2ff2ffa","node_kind":"variable","node_label":"Name","type":"missing_data","message":"client.name has no value and rendered blank","severity":"warning","path":"client.name"},{"node_id":"d7c835fd890e","node_kind":"variable","node_label":"unit_price","type":"missing_data","message":"items[1].unit_price has no value and rendered blank","severity":"warning","path":"items[1].unit_price"}]Two values were left out of the payload: the client name, and the unit price of the second item.
[
{
"node_id": "dc8fc2ff2ffa",
"node_kind": "variable",
"node_label": "Name",
"type": "missing_data",
"message": "client.name has no value and rendered blank",
"severity": "warning",
"path": "client.name"
},
{
"node_id": "d7c835fd890e",
"node_kind": "variable",
"node_label": "unit_price",
"type": "missing_data",
"message": "items[1].unit_price has no value and rendered blank",
"severity": "warning",
"path": "items[1].unit_price"
}
]Fields
- node_id
- The id of the node in the template that produced it. Stable across renders.
- node_kind
- What kind of node: variable, formula, aggregation, loop, image, conditional, document.
- node_label
- The label shown on that node in the editor, or null.
- type
- What went wrong. See the table below.
- message
- One sentence, already written for a person to read.
- severity
- Always "warning" in this header: the document rendered. "error" appears in engine_errors on a refused render, and in the errors list that validate returns.
- path
- Where the value lives in your payload, with list indexes: client.name, items[1].unit_price. Null when the warning is not about one value.
Reading the path
path is the exact place in your own payload, so you can jump to it
without searching. Nested objects use dots
(client.name) and rows of a list carry their index
(items[1].unit_price, the second row). When a calculation is the thing
that failed, the path names the operand that stopped it, written the way the calculation reads it, which
inside a repeated row is the short form
(unit_price rather than
items[1].unit_price).
What to do with them
Log the array against the document you just produced, and alert on it if a gap should never happen in your flow. If a gap should never happen at all, do not log it: render with strict and get a 422 instead of a file. The array is also stored on the document in your Documents history, so it is there after the fact.
Types you can receive
| type | Meaning |
|---|---|
| missing_data | A blank the document reads has no value in the payload, so it rendered blank. Also raised when a calculation stops because one of its operands has no value. |
| type_mismatch | A value is present but does not fit its declared type, for example the text "two" where a number is declared. |
| fallback_used | A blank had no value and rendered the default text typed on it in the editor. Strict mode does not refuse on this one. |
| division_by_zero | A calculation divided by zero, so it rendered blank. |
| invalid_image_source | An image node points at something the renderer cannot read. |
| insecure_image_url | An image node points at a plain http URL. |
| invalid_formatter | A format on a blank or a calculation could not be applied to the value. |
| invalid_loop_target | A repeated section points at something that is not a list. |
| invalid_formula | A calculation could be read but not evaluated here, for example one that refers to itself, or a total asked for over something that is not a list. It renders blank. |
| non_numeric_majority | A total was asked for over values that are mostly not numbers. |
x-docmake-warnings: [{"node_id":"","node_kind":"variable","node_label":"qty","type":"type_mismatch","message":"items[1].qty: expected a number, got \"two\"","severity":"warning","path":"items[1].qty"}]The same request with "qty": "two" on the second item. The document still rendered.
Strict mode: refuse instead of shipping a gap
Send "strict": true
alongside data and
nothing is rendered when a value is missing or has the wrong type. You get 422 and the full list instead of a
document with holes in it. Two values missing, two entries.
curl -sS -X POST https://app.docmake.io/api/v1/render \
-H "Authorization: Bearer dm_xxx" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tmpl_ow4Jl7QC7tEw",
"format": "pdf",
"strict": true,
"data": {
"date": "2026-09-02",
"client": {
"address": "Str. Exemplu 10, Bucuresti",
"cui": "RO12345678",
"contact": "Maria Ionescu"
},
"items": [
{ "product": "Consultanta ISO 9001", "qty": 1, "unit_price": 2500 },
{ "product": "Audit intern", "qty": 2 },
{ "product": "Instruire personal", "qty": 3, "unit_price": 350 }
],
"discount_percent": 10,
"total": 4635,
"signer": { "name": "Elena Marin", "title": "Director" }
}
}'HTTP/2 422
{
"error": {
"type": "missing_data",
"message": "Nothing was rendered: 2 values are missing. Fix the values listed in details, or render without strict.",
"missing": [
{
"key": "client.name",
"path": "client.name",
"label": "Name",
"message": "client.name has no value"
},
{
"key": "items.unit_price",
"path": "items[1].unit_price",
"label": "unit_price",
"message": "items[1].unit_price has no value"
}
],
"details": [
{
"path": "client.name",
"key": "client.name",
"label": "Name",
"type": "missing_data",
"message": "client.name has no value"
},
{
"path": "items[1].unit_price",
"key": "items.unit_price",
"label": "unit_price",
"type": "missing_data",
"message": "items[1].unit_price has no value"
}
]
}
}missing keeps the older key-based list. details is the full list, missing values and wrong types together, in the shape a form can walk.
Nothing missing, only a wrong type
The envelope type switches to
type_mismatch and
missing comes back empty. Numeric strings count as numbers and
"true" and "false" count as booleans, because that is how values arrive from forms and from JSON alike.
What strict checks
- • Only what the document actually reads. A definition nothing on the page uses is never missing.
- • A calculation is an output, never an input, so it is never reported as missing.
- • Absent, null and the empty string are all "no value". Zero and false are values.
- • A blank that has a default value applied is filled in before the check, so it never counts as missing.
- • A blank carrying its own default text on the chip renders that text and warns as
fallback_used, which strict does not refuse.
HTTP/2 422
{
"error": {
"type": "type_mismatch",
"message": "Nothing was rendered: 1 value has the wrong type. Fix the values listed in details, or render without strict.",
"missing": [],
"details": [
{
"path": "items[1].qty",
"key": "items.qty",
"label": "qty",
"type": "type_mismatch",
"message": "items[1].qty: expected a number, got \"two\""
}
]
}
}
Over MCP the polarity is the other way round: render_document is strict
unless you pass strict: false, so an assistant asks you for a value
rather than inventing one. Over REST the default is off, so an existing integration keeps behaving the way it
did.
Defaults and examples are different things
Every blank (a variable in the API)
carries two value fields, and only one of them ever reaches a real document.
default
Filled in before the render
Set in the editor on the blank's definition. Applied to every render entry point before validation runs: absent, null and empty-string values take the default, and a defaulted value never counts as missing, not even in strict mode. Under a list, the default fills every row that lacks the value. An absent list is never invented.
example
Preview only, never rendered
Sample data, auto-filled so the editor preview has something to show and so the API snippet in the app has a realistic body. It is never injected into a real render. Leave a blank out of your payload and the document shows nothing there, plus a warning; it never shows the sample.
Reading both from the API
GET /api/v1/templates/{id}/form-schema returns the field tree a form
can be generated from: the dotted key where each value belongs, its
declared type, whether it is
required, and both value fields. Definitions the document does not read
are listed separately under unused and are never required.
Both fields are set in the editor, not over the API: creating or updating a template through
POST or PATCH /api/v1/templates
writes the name and the document schema only.
curl -sS https://app.docmake.io/api/v1/templates/tmpl_ow4Jl7QC7tEw/form-schema \
-H "Authorization: Bearer dm_xxx"{
"template_id": "tmpl_ow4Jl7QC7tEw",
"name": "oferta",
"fields": [
{
"key": "date",
"name": "date",
"label": "date",
"type": "date",
"required": true,
"default": null,
"example": "2026-09-30"
},
{
"key": "discount_percent",
"name": "discount_percent",
"label": "discount_percent",
"type": "number",
"required": true,
"default": null,
"example": "3"
},
...
],
"unused": [ ... ]
}These two blanks have no default, so leaving them out of a payload renders nothing and warns. The examples, 2026-09-30 and 3, only ever appear in the editor preview.
Rules and calculations
Both are built with pickers in the editor, and both end up as nodes in the document schema you can read and write over the API. Neither needs anything extra in your payload: you send the values, the document does the arithmetic and the choosing.
Only when: a rule that shows or hides
A show-if rule (a docmakeConditional node) wraps part of the document
and keeps it only when the rule holds. A rule is either one comparison
(field, op,
value) or a group with
logic set to and or
or over a non-empty
conditions array. Groups nest up to five deep.
A rule that cannot be read is named rather than ignored:
invalid_condition for a missing field, a logic word that is not
and/or, an empty group or one nested too deep; invalid_operator for
an operator outside the ten below; missing_value for a comparison
with no value to compare against. All three are errors, so the render is refused with
render_error rather than guessed at.
{
"type": "docmakeConditional",
"attrs": {
"condition": { "field": "discount_percent", "op": "gt", "value": "0" }
}
}| op | Reads as | Note |
|---|---|---|
| eq | is | Loose comparison, so the number 10 and the text "10" match. |
| neq | is not | The opposite of eq. |
| gt | is more than | False unless the value in the data is numeric. |
| gte | is at least | False unless the value in the data is numeric. |
| lt | is less than | False unless the value in the data is numeric. |
| lte | is at most | False unless the value in the data is numeric. |
| in | is one of | The rule value must be an array. |
| nin | is none of | The rule value must be an array. |
| empty | has no value | True when absent, null, empty text or an empty list. |
| nempty | has a value | The opposite of empty. |
Calculate: arithmetic in the document
A calculation (a docmakeFormula node) holds one
expression: numbers, field paths, the four operators
+ - * /, brackets, and the aggregates
SUM, AVG,
COUNT, MIN and
MAX over a list. Its
name makes the result a field other calculations can read.
A path resolves innermost first. Inside a row repeated over
items, qty is that row's own
qty; outside it, SUM(items.line_total) adds up the row calculation
across every row, and SUM(items.qty * items.unit_price) evaluates the
product once per row and adds the results. A named calculation is read by name:
total - total * discount_percent / 100.
Results print plainly: no trailing zeros, at most two decimals, the render locale's decimal separator, and no thousands grouping unless the calculation carries a number or currency format.
{
"type": "docmakeFormula",
"attrs": {
"id": "f_line",
"name": "line_total",
"label": "Line total",
"expression": "qty * unit_price",
"formatters": []
}
}Quote for Northwind Trading SRL
Product Qty Unit price Line total
Consulting 1 2500 2500
Internal audit 2 800 1600
Training 3 350 1050
Total: 5150
Total after discount: 4635Three rows, a line total each, SUM(items.line_total) underneath, then total minus its percentage. Nothing in the payload carried a total.
When a calculation cannot finish
A missing, empty or non-numeric operand stops the calculation, and so does dividing by zero. It renders blank and warns; it never quietly sums the rows it could read. The warning names the calculation and the operand that stopped it, and the failure travels up: a broken row total takes the grand total with it.
The warning type says which of the three it was:
missing_data,
type_mismatch or
division_by_zero.
An expression that is not readable at all is a different thing: it is an error, not a warning, and the
render is refused. POST /api/v1/templates/{id}/validate reports the
same thing without rendering, so you can check a template in CI.
[
{
"node_id": "v_price",
"node_kind": "variable",
"node_label": "unit_price",
"type": "missing_data",
"message": "items[1].unit_price has no value and rendered blank",
"severity": "warning",
"path": "items[1].unit_price"
},
{
"node_id": "f_line",
"node_kind": "formula",
"node_label": "Line total",
"type": "missing_data",
"message": "items[1].line_total could not be calculated: unit_price has no value",
"severity": "warning",
"path": "unit_price"
},
{
"node_id": "f_total",
"node_kind": "formula",
"node_label": "Total",
"type": "missing_data",
"message": "total could not be calculated: unit_price has no value",
"severity": "warning",
"path": "unit_price"
},
{
"node_id": "f_net",
"node_kind": "formula",
"node_label": "Total after discount",
"type": "missing_data",
"message": "total_after_discount could not be calculated: unit_price has no value",
"severity": "warning",
"path": "unit_price"
}
]Four warnings from one gap: the blank itself, its row total, the sum, and the discounted total.
curl -sS -X POST https://app.docmake.io/api/v1/templates/tmpl_xxx/validate \
-H "Authorization: Bearer dm_xxx" \
-H "Content-Type: application/json" \
-d '{"data":{"client":{"name":"Northwind Trading SRL"},"discount_percent":10,
"items":[{"product":"Consulting","qty":1,"unit_price":2500}]}}'{
"valid": false,
"errors": [
{
"node_id": "f_line",
"node_kind": "formula",
"node_label": "Line total",
"type": "invalid_formula",
"message": "Multiply by what?",
"severity": "error",
"path": null
}
]
}The calculation read "qty * " with nothing after the operator. valid is false, so a render would be refused with render_error.
Fonts
Twenty families ship with every workspace, four faces each: regular, bold, italic and bold italic, so bold and
italic are real cuts rather than something the renderer fakes. Text with no font set renders in Inter. The PDF
renderer reads faces off disk and never fetches one over the network, so a render never waits on a font CDN. The
time it actually took comes back in X-DocMake-Render-Time-Ms.
Sans
- Inter
- Arial
- Calibri
- Roboto
- Open Sans
- Lato
- Montserrat
- Poppins
- Nunito
- Source Sans 3
Serif
- Times New Roman
- Georgia
- Cambria
- Merriweather
- Lora
- Playfair Display
- Source Serif 4
Monospace
- Courier New
- Roboto Mono
- JetBrains Mono
Microsoft names, free twins
Six of the bundled families are Microsoft names that cannot be redistributed. They render in metric compatible substitutes: the same widths and line breaks, so a page keeps its shape, and the name in the schema and in the DOCX stays the one you picked.
| Family in your template | Rendered with |
|---|---|
| Arial | Arimo |
| Times New Roman | Tinos |
| Courier New | Cousine |
| Calibri | Carlito |
| Cambria | Caladea |
| Georgia | Gelasio |
Your own fonts
Upload a brand font under Fonts in the app: TTF, OTF, WOFF or WOFF2, up to 5 MB per file and 25 files per workspace, one upload per weight and style. Uploads are per workspace and are not part of the REST API.
What ends up in the file
- • PDF: every face the document actually uses is embedded in the file as base64, bundled and uploaded alike, plus Inter for anything unstyled. Faces the document does not use are not embedded.
- • DOCX: only your own uploads are embedded, as obfuscated font parts the way Word writes them, so a recipient who does not have the font still sees it. Bundled families are deliberately not embedded; Word resolves them locally.
- • A family that is neither bundled nor uploaded has nothing to embed. The editor surfaces it so you can upload the real font instead of finding out from a customer.
Limits, keys and workspaces
Rate limit
One per-minute budget per workspace: 30 requests a minute on Free, 120 on Pro, 600 on Enterprise. Every
response carries X-RateLimit-Limit,
X-RateLimit-Remaining and
X-RateLimit-Reset (a Unix timestamp), so an integration can pace
itself rather than guess. Over budget is a 429 with
Retry-After in seconds. One MCP message counts as one request
against the same budget.
Document allowance
1,000 documents a month on Free and 10,000 on Pro, counted per workspace over the billing period. Templates are unlimited on every plan. Running out answers 429 with the quota body described above.
Keys and scope
A key you create in Settings → API Keys is shown once and stored only as a SHA-256 hash; the key your
workspace starts with can be revealed again from that page, and every reveal is written to the team's
activity feed. A key carries the workspace it was made in: every request acts as that workspace's owner and
can reach nothing else. Two keys on Free, unlimited on Pro. Deleting a key stops it working on the next
request. Send it as Authorization: Bearer dm_..., over HTTPS, never
in a query string.
{
"data": {
"plan": "free",
"billing_period": {
"start": "2026-09-02T11:57:42+00:00",
"end": "2026-10-02T11:57:42+00:00"
},
"renders": {
"used": 36,
"limit": 1000
},
"templates": {
"used": 4,
"limit": null
},
"credits": {
"used": 0,
"limit": 20
},
"api_calls": {
"used": 0,
"limit": 500
},
"rate_limit": {
"requests_per_minute": 30
}
}
}Plan, billing window, documents used against the monthly allowance, template count, AI credits, and the per-minute rate limit for this workspace.
Endpoints
The whole surface. Everything authenticates the same way, with
Authorization: Bearer dm_...
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/templates | List templates |
| POST | /api/v1/templates | Create a template from a document schema |
| GET | /api/v1/templates/{id} | Get a template, schema and variable definitions included |
| PATCH | /api/v1/templates/{id} | Update a template name or schema |
| DELETE | /api/v1/templates/{id} | Delete a template |
| POST | /api/v1/templates/{id}/duplicate | Duplicate a template |
| GET | /api/v1/templates/{id}/form-schema | The field tree the template expects, with type, default and example |
| POST | /api/v1/templates/{id}/validate | Check a template, and optionally a data payload, without rendering |
| POST | /api/v1/templates/{id}/compute-aggregations | Evaluate the calculations in a document against data |
| POST | /api/v1/templates/import | Import a DOCX file as a template |
| POST | /api/v1/render | Render one document, the response body is the DOCX or PDF file |
| POST | /api/v1/render/batch | Queue up to 100 documents as one job |
| GET | /api/v1/render/jobs/{id} | Batch job status, per item |
| GET | /api/v1/render/jobs/{id}/download | Download batch results, a file or a ZIP |
| POST | /api/v1/assets | Upload an image asset |
| GET | /api/v1/assets | List assets |
| DELETE | /api/v1/assets/{id} | Delete an asset |
| GET | /api/v1/usage | Plan, quota and rate limit for the current period |
Run it in Postman
The DocMake API collection is public, with a request per endpoint and saved example responses in the shapes
on this page, strict renders and warnings included. Fork it into your own workspace, set base_url and your dm_ key, and send the first
render before you write any code.
Reading rather than running? The same collection is published as API reference documentation, request by request, alongside the rest of the public DocMake workspace.
Prefer no code at all?
Connect Claude, Cursor, or any MCP client to DocMake with the official MCP server. The same six capabilities, the same key, and renders that refuse rather than invent.
Try it with 1,000 free documents a month
The Free plan includes full API access. No card, no expiry.
Get an API key