Stencilry API (v1)

Download OpenAPI specification:Download

Getting started

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.

1. Get an 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.

2. Check that it works

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.

3. Mint further keys from the API

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.

Next

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.

Generate a PDF

A stored template plus a payload gives you a PDF. This is the call your server makes in production.

1. Create the template

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.

2. Generate

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

3. Read the warnings

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.

Status codes

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.

Preview and diagnostics

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.

The call

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.

The success body is multipart, not JSON

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.

A failed render is 422, not 400

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.

A clean render is not a correct document

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.

Errors and retries

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.

The problem shape

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.

What to do with each code

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

500 is retryable here, and that is deliberate

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.

503 and backoff

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

What never to retry

A 400, 401, 404, 409 or 422 will fail identically however many times you send it.

Idempotency

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.

Templates

Saves a Template in place.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string <guid>
Request Body schema: application/json
required
name
required
string non-empty
description
string or null
body
string or null
payload
string or null
(PageOptionsRequest (object or null))

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "body": "string",
  • "payload": "string",
  • "page": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "description": "string",
  • "version": 0,
  • "revisionCount": 0,
  • "body": "string",
  • "payload": "string",
  • "page": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Renames a Template.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string <guid>
Request Body schema: application/json
required
name
required
string non-empty

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "description": "string",
  • "version": 0,
  • "revisionCount": 0,
  • "body": "string",
  • "payload": "string",
  • "page": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Fetches one of the caller's templates, active or archived.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string <guid>

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "description": "string",
  • "version": 0,
  • "revisionCount": 0,
  • "body": "string",
  • "payload": "string",
  • "page": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Deletes one of the caller's Templates.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string <guid>

Responses

Response samples

Content type
application/problem+json
{
  • "title": "One or more validation errors occurred.",
  • "status": 400,
  • "instance": "/api/route",
  • "traceId": "0HMPNHL0JHL76:00000001",
  • "detail": "string",
  • "errors": [
    ]
}

Lists a Template's Revisions.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string <guid>

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Archives one of the caller's Templates.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "version": 0,
  • "name": "string",
  • "description": "string",
  • "body": "string",
  • "payload": "string",
  • "page": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Lists the caller's Templates.

Most recently updated first. Entries are summaries: no Liquid body, no payload, no Revisions. Not paged.

Authorizations:
JWTBearerAuthX-Api-Key

Responses

Request samples

# 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)"'

Response samples

Content type
application/json
[
  • {
    }
]

Creates a Template.

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.

Authorizations:
JWTBearerAuthX-Api-Key
Request Body schema: application/json
required
name
required
string non-empty
description
string or null
body
string or null
payload
string or null
(PageOptionsRequest (object or null))

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "body": "string",
  • "payload": "string",
  • "page": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "description": "string",
  • "version": 0,
  • "revisionCount": 0,
  • "body": "string",
  • "payload": "string",
  • "page": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

Generates a Document from a stored Template.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
templateId
required
string
Request Body schema: application/json
required
payloadJson
string or null

Responses

Request samples

Content type
application/json
{
  • "payloadJson": "string"
}

Response samples

Content type
application/json
{
  • "document": "string",
  • "warnings": [
    ]
}

Rendering

Renders an HTML template and a JSON payload to a PDF.

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.

Authorizations:
JWTBearerAuthX-Api-Key
Request Body schema: application/json
required
templateHtml
string or null
payloadJson
string or null
(PageOptionsRequest (object or null))

Responses

Request samples

Content type
application/json
{
  • "templateHtml": "string",
  • "payloadJson": "string",
  • "page": {
    }
}

Response samples

Content type
application/problem+json
{
  • "title": "One or more validation errors occurred.",
  • "status": 400,
  • "instance": "/api/route",
  • "traceId": "0HMPNHL0JHL76:00000001",
  • "detail": "string",
  • "errors": [
    ]
}

API keys

Revokes one of the caller's keys.

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.

Authorizations:
JWTBearerAuthX-Api-Key
path Parameters
id
required
string

Responses

Response samples

Content type
application/problem+json
{
  • "title": "One or more validation errors occurred.",
  • "status": 400,
  • "instance": "/api/route",
  • "traceId": "0HMPNHL0JHL76:00000001",
  • "detail": "string",
  • "errors": [
    ]
}

The keys the caller holds, most recently updated first.

Revoked keys are included, carrying the instant they were revoked. The token is not returned: only the prefix survives issuing.

Authorizations:
JWTBearerAuthX-Api-Key

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Creates a key and returns its token once.

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.

Authorizations:
JWTBearerAuthX-Api-Key
Request Body schema: application/json
required
name
required
string [ 0 .. 128 ] characters
description
string or null [ 0 .. 2048 ] characters

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string"
}

Response samples

Content type
application/json
{
  • "key": {
    },
  • "token": "string"
}

Capabilities

What the renderer and the template engine support.

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.

Responses

Response samples

Content type
application/problem+json
{
  • "title": "One or more validation errors occurred.",
  • "status": 400,
  • "instance": "/api/route",
  • "traceId": "0HMPNHL0JHL76:00000001",
  • "detail": "string",
  • "errors": [
    ]
}

Diagnostics

Reports that the API host is running.

Responses

Response samples

Content type
application/json
{
  • "status": "string",
  • "buildNumber": "string",
  • "environment": "string",
  • "utcNow": "2019-08-24T14:15:22Z"
}