Download OpenAPI specification:Download
Stencilry turns a Liquid template and a JSON payload into a PDF. You author the template once, then post payloads against it.
Everything below calls https://api.stencilry.dev and uses $STENCILRY_KEY for your API key.
Two credentials open this API, and they are interchangeable — every secured operation accepts either:
| Credential | Header | Who uses it |
|---|---|---|
| API key | X-Api-Key: <token> |
Your server |
| Access token | Authorization: Bearer <jwt> |
The studio, in a browser |
POST /api-keys needs a credential itself, so your first key comes from the studio: sign in, open
API keys, and create one.
The token is returned once, by the call that creates the key. It is never readable again — the
list only carries prefix, the first 12 characters, so one key can be told from another. Store it
before you close the dialog.
curl -s "https://api.stencilry.dev/templates" -H "X-Api-Key: $STENCILRY_KEY"
A 200 with a JSON array — [] on a new account — means the key is live:
[
{
"id": "0d6b9a5e-1f3c-4a7d-9c2b-5f8e1a0b7c34",
"name": "Invoice",
"description": "Monthly invoice",
"version": 3,
"createdAt": "2026-09-01T09:14:22.104Z",
"updatedAt": "2026-09-18T16:02:47.881Z"
}
]
A 401 means the header is missing or the key was revoked.
Once you hold one key you can mint more without the studio:
curl -s -X POST "https://api.stencilry.dev/api-keys" \
-H "X-Api-Key: $STENCILRY_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"billing-worker","description":"Nightly invoice run"}'
{
"key": {
"id": "8f2c1b44-7a90-4d16-b3e8-2c5d90ab1f77",
"name": "billing-worker",
"description": "Nightly invoice run",
"prefix": "stcl_xvfb8Gx",
"createdAt": "2026-09-21T11:40:03.221Z",
"revokedAt": null
},
"token": "stcl_xvfb8GxK3mQ7pT2aL9dR4wN6yB1cE8sV"
}
Names are unique across the keys you hold that are not revoked — reusing a live name is a 409.
Revoking a key frees its name.
Generate a PDF posts a payload against a stored template. Preview and diagnostics renders template and payload together without storing anything, which is the loop to use while authoring.
A stored template plus a payload gives you a PDF. This is the call your server makes in production.
curl -s -X POST "https://api.stencilry.dev/templates" \
-H "X-Api-Key: $STENCILRY_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoice",
"description": "Monthly invoice",
"body": "<h1>Invoice {{ number }}</h1><p>Due {{ due }}</p><p>Total {{ total }}</p>",
"payload": "{\"number\":\"INV-001\",\"due\":\"2026-10-01\",\"total\":\"R 1 240.00\"}"
}'
body is the Liquid template. payload is a sample payload stored beside it — the studio uses it
for previews, and it documents the shape the template expects. Both are optional at creation and can
be filled in later with PUT /templates/{id}.
The response carries the template's id. Keep it; it is the only handle to the template.
curl -s -X POST "https://api.stencilry.dev/templates/$TEMPLATE_ID/generate" \
-H "X-Api-Key: $STENCILRY_KEY" \
-H "Content-Type: application/json" \
-d '{"payloadJson":"{\"number\":\"INV-042\",\"due\":\"2026-11-01\",\"total\":\"R 3 980.00\"}"}'
Note that payloadJson is a string holding JSON, not a nested object. It is the payload the
template renders against.
{
"document": "JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGgg...",
"warnings": []
}
document is the PDF, base64. Decode it to bytes and write the file:
curl -s -X POST "https://api.stencilry.dev/templates/$TEMPLATE_ID/generate" \
-H "X-Api-Key: $STENCILRY_KEY" \
-H "Content-Type: application/json" \
-d "{\"payloadJson\":$(jq -Rs . < payload.json)}" \
| jq -r .document | base64 -d > invoice.pdf
warnings is almost always empty and is never a reason to fail the call — the PDF in document is
complete either way. A warning names something the renderer could not honour:
{
"document": "JVBERi0xLjcK...",
"warnings": [
{
"code": "template.undefined_field",
"message": "The template refers to 'customer.vatNumber', which the payload does not contain. It rendered as empty text.",
"location": { "line": null, "column": null, "jsonPath": "$.customer.vatNumber" }
}
]
}
The codes:
code |
Means |
|---|---|
template.undefined_field |
The template read a field the payload does not contain. It rendered as empty text |
render.asset_missing |
The template referenced an asset you have not stored. It rendered as nothing |
render.asset_remote |
The template referenced a remote URL. Remote assets are never fetched — upload it and refer to it by name |
render.asset_unreadable |
The stored asset is not the kind of file it claims to be. It rendered as nothing |
render.asset_fetch_failed |
The asset exists but could not be read from storage this run |
location is nullable, and so is each of its three fields.
Log warnings.
| Code | Means |
|---|---|
200 |
The PDF is in document. Check warnings |
400 |
payloadJson is not well-formed JSON, the template has no Liquid body, or the render failed |
404 |
No active template with that id. A deleted or archived template is a 404 |
500 |
The PDF rendered but the run could not be recorded, so it is withheld. Retry |
503 |
No render slot came free. Retry with backoff |
Errors and retries covers 500 and 503 in full.
POST /render/preview renders a template and a payload together and stores nothing. Use it while
authoring: no template has to exist, and nothing you send is kept.
curl -s -X POST "https://api.stencilry.dev/render/preview" \
-H "X-Api-Key: $STENCILRY_KEY" \
-H "Content-Type: application/json" \
-d '{
"templateHtml": "<h1>Invoice {{ number }}</h1><p>Total {{ total }}</p>",
"payloadJson": "{\"number\":\"INV-042\",\"total\":\"R 3 980.00\"}",
"page": { "size": "A4", "orientation": "Portrait" }
}' \
-o preview.multipart -D headers.txt
page is optional and defaults to A4 portrait.
A 200 is multipart/form-data with two parts:
| Part | Content type | Holds |
|---|---|---|
diagnostics |
application/json |
Page count, errors, warnings |
pdf |
application/pdf |
The raw bytes, filename preview.pdf |
Multipart rather than a base64 envelope because base64 adds a third to the size of every render, and
an authoring loop renders constantly. The boundary is generated per response and is in the
Content-Type header — read it from there, never assume it.
The parts arrive in that order. The sample beside this operation shows the read in each language.
| Code | Body | Means |
|---|---|---|
200 |
multipart | It rendered. Read diagnostics.warnings anyway |
400 |
problem details | The request envelope itself could not be bound |
422 |
diagnostics JSON | The request was read; the template or the payload is wrong |
503 |
diagnostics JSON | No render slot came free. Nothing was attempted |
422 is the one to handle, and it is wider than it looks: a payloadJson that is not well-formed
JSON lands here too, not on 400. This differs from POST /templates/{id}/generate, where malformed
payloadJson is a 400.
The body is the same diagnostics object, without a PDF:
{
"pageCount": 0,
"errors": [
{
"code": "TemplateSyntax",
"message": "End of tag '}}' was expected at (1:14)",
"location": { "line": 1, "column": 14, "jsonPath": null }
}
],
"warnings": []
}
code is one of JsonParse, TemplateSyntax, TemplateRuntime, Render, Timeout. They cross
the wire as names, never as numbers. Malformed payloadJson gives JsonParse.
The renderer reports nothing when it ignores something — no exception, no warning, no log entry. CSS it does not support is parsed and then dropped, and the page comes back looking finished.
So 200 with errors: [] and warnings: [] means the render completed. It does not mean the
document is right. Open the PDF and look at the pages before you ship a template.
Every failure that carries a body uses RFC 9457 problem details, served as
application/problem+json. The two render endpoints are the exception on some codes, and return
diagnostics instead — those are listed below.
POST /templates with no name:
{
"type": "https://www.rfc-editor.org/rfc/rfc7231#section-6.5.1",
"title": "Bad Request",
"status": 400,
"instance": "/templates",
"traceId": "0HNONU92PQJJV:00000001",
"detail": "A name is required.",
"errors": [
{ "name": "name", "reason": "A name is required." }
]
}
errors[].name is the request field that caused it, or generalErrors when the failure is not about
a field — a 404 reads {"name":"generalErrors","reason":"No Template exists with that id."}.
Quote traceId when you report a problem. It identifies the single request in our logs.
| Code | Retry? | Do |
|---|---|---|
400 |
No | Fix the request. errors names the field |
401 |
No | The credential is missing, malformed or revoked. Mint a new key |
404 |
No | The template id does not exist, or is archived or deleted |
409 |
No | An API key with that name already exists and is not revoked |
422 |
No | The render ran and the template or payload is wrong. Read the diagnostics |
500 |
Yes | Retry — see below |
503 |
Yes | No render slot came free. Retry with backoff |
On POST /templates/{id}/generate and POST /render/preview, a 500 has a specific meaning: the
PDF rendered, and the service could not record the run. The document is withheld rather than
returned unrecorded.
Nothing was charged and nothing was kept. Retry the identical request.
Renders run in a fixed number of slots. When none comes free within the wait, the request is refused
without being attempted — 503, with a diagnostics body whose errors is empty and pageCount is
0. Nothing rendered.
Retry with exponential backoff and jitter. Three attempts at 1s, 2s and 4s, each ±20%, clears ordinary contention:
for attempt in 1 2 3; do
code=$(curl -s -o out.json -w '%{http_code}' -X POST "https://api.stencilry.dev/templates/$TEMPLATE_ID/generate" \
-H "X-Api-Key: $STENCILRY_KEY" -H "Content-Type: application/json" \
-d "{\"payloadJson\":$(jq -Rs . < payload.json)}")
[ "$code" = "200" ] && break
[ "$code" = "500" ] || [ "$code" = "503" ] || break
sleep $(( 2 ** (attempt - 1) ))
done
A 400, 401, 404, 409 or 422 will fail identically however many times you send it.
POST /templates/{id}/generate and POST /render/preview have no side effect you can observe beyond
a recorded run, so a retry is safe. POST /templates and POST /api-keys create things — a blind
retry after a timeout can leave you with two. Check with GET /templates or GET /api-keys before
sending again.
Writes the name, description, Liquid body, sample payload and page options as they are given; a request with no page field clears the stored page options. The identifier and Version do not change and no Revision is created; Archive is the only thing that makes revisions. An id naming no Template of the caller's is 404.
| id required | string <guid> |
| name required | string non-empty |
| description | string or null |
| body | string or null |
| payload | string or null |
(PageOptionsRequest (object or null)) |
{- "name": "string",
- "description": "string",
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}
}{- "id": "string",
- "name": "string",
- "description": "string",
- "version": 0,
- "revisionCount": 0,
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Writes the name and nothing else: the description, Liquid body and sample payload are left as they are, the Version does not change and no Revision is created. Save replaces all four fields; this does not. An id naming no Template of the caller's is 404.
| id required | string <guid> |
| name required | string non-empty |
{- "name": "string"
}{- "id": "string",
- "name": "string",
- "description": "string",
- "version": 0,
- "revisionCount": 0,
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Name, description, Liquid body, sample payload, page options, Version and Revision count. A Revision is a template too, so its own id fetches it here; read those ids from the revisions listing. An id naming nothing and another user's id are both 404.
| id required | string <guid> |
{- "id": "string",
- "name": "string",
- "description": "string",
- "version": 0,
- "revisionCount": 0,
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}The Template and every Revision under it are removed, explicitly and in one transaction. An id naming no Template of the caller's — including a Revision's own id — is 404, and nothing is removed.
| id required | string <guid> |
{- "title": "One or more validation errors occurred.",
- "status": 400,
- "instance": "/api/route",
- "traceId": "0HMPNHL0JHL76:00000001",
- "detail": "string",
- "errors": [
- {
- "name": "Error or field name",
- "reason": "Error reason",
- "code": "string",
- "severity": "string"
}
]
}Newest Version first. Entries are summaries: no body, no payload. Each carries the Revision's own id, which GET /templates/{id} reads it back by — the only route to a Revision's content. A Template never Archived has an empty list, not an error. An id naming no Template of the caller's is 404.
| id required | string <guid> |
[- {
- "id": "string",
- "version": 0,
- "name": "string",
- "description": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
]The Template's current state — name, description, body, payload — becomes a Revision numbered with the Template's current Version, and the Template moves to the next Version. Archiving unchanged or empty content is allowed. A Note in the body becomes the Template's description first, so the Revision carries it; a request with no body leaves the description as it is. An id naming no Template of the caller's is 404; a concurrent Archive that lost the race is 409.
| id required | string |
{- "id": "string",
- "version": 0,
- "name": "string",
- "description": "string",
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Most recently updated first. Entries are summaries: no Liquid body, no payload, no Revisions. Not paged.
# Cheapest call that proves a credential is live. 200 with [] on a new account. curl -s "https://api.stencilry.dev/templates" \ -H "X-Api-Key: $STENCILRY_KEY" \ | jq -r '.[] | "\(.id) v\(.version) \(.name)"'
[- {
- "id": "string",
- "name": "string",
- "description": "string",
- "version": 0,
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
]The name is required; the Liquid body, the sample payload and the page options are optional. Absent page options mean the defaults apply. A new Template is Version 1 with no Revisions. Names need not be unique.
| name required | string non-empty |
| description | string or null |
| body | string or null |
| payload | string or null |
(PageOptionsRequest (object or null)) |
{- "name": "string",
- "description": "string",
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}
}{- "id": "string",
- "name": "string",
- "description": "string",
- "version": 0,
- "revisionCount": 0,
- "body": "string",
- "payload": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}, - "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Renders the Template's current content with the page options stored on it, using the request's payloadJson when supplied and {} when not — the sample payload is never a render default. Assets resolve from the caller's stored assets. On success the response is one JSON body with the base64 PDF and any warnings. Failures are problem details.
| templateId required | string |
| payloadJson | string or null |
{- "payloadJson": "string"
}{- "document": "string",
- "warnings": [
- {
- "code": "string",
- "message": "string",
- "location": {
- "line": 0,
- "column": 0,
- "jsonPath": "string"
}
}
]
}Requires an access token or an API key. On success the response is multipart/form-data with two parts: 'diagnostics' (application/json) and 'pdf' (application/pdf, raw bytes). A render that ran and failed returns the same diagnostics JSON on its own, as 422; a request the service could not read at all is 400.
| templateHtml | string or null |
| payloadJson | string or null |
(PageOptionsRequest (object or null)) |
{- "templateHtml": "string",
- "payloadJson": "string",
- "page": {
- "size": "A3",
- "orientation": "Portrait",
- "margins": {
- "top": 0.1,
- "right": 0.1,
- "bottom": 0.1,
- "left": 0.1
}
}
}{- "title": "One or more validation errors occurred.",
- "status": 400,
- "instance": "/api/route",
- "traceId": "0HMPNHL0JHL76:00000001",
- "detail": "string",
- "errors": [
- {
- "name": "Error or field name",
- "reason": "Error reason",
- "code": "string",
- "severity": "string"
}
]
}Revoking a key that is already revoked changes nothing and still answers 204. An id naming no key, and an id naming another user's key, are both 404.
| id required | string |
{- "title": "One or more validation errors occurred.",
- "status": 400,
- "instance": "/api/route",
- "traceId": "0HMPNHL0JHL76:00000001",
- "detail": "string",
- "errors": [
- {
- "name": "Error or field name",
- "reason": "Error reason",
- "code": "string",
- "severity": "string"
}
]
}Revoked keys are included, carrying the instant they were revoked. The token is not returned: only the prefix survives issuing.
[- {
- "id": "string",
- "name": "string",
- "description": "string",
- "prefix": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z"
}
]The token is in this response and in no other. A name the caller already uses on a key that is not revoked is rejected; revoking a key frees its name.
| name required | string [ 0 .. 128 ] characters |
| description | string or null [ 0 .. 2048 ] characters |
{- "name": "string",
- "description": "string"
}{- "key": {
- "id": "string",
- "name": "string",
- "description": "string",
- "prefix": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z"
}, - "token": "string"
}Markdown: what never works, that nothing is reported when something is dropped, and the CSS this deployment's renderer parses and then ignores. The same text the MCP describe_capabilities tool returns.
{- "title": "One or more validation errors occurred.",
- "status": 400,
- "instance": "/api/route",
- "traceId": "0HMPNHL0JHL76:00000001",
- "detail": "string",
- "errors": [
- {
- "name": "Error or field name",
- "reason": "Error reason",
- "code": "string",
- "severity": "string"
}
]
}