TEMPLATES · DATA CONTRACTS
Every template is a typed API
doclinth reads each template's Handlebars structure and derives a Draft 2020-12 JSON Schema for the data it expects — objects, arrays, primitives, optional fields, and image/URI fields. The schema is regenerated on every publish, so it is always true to the template you ship. Fetch it, generate types from it, or validate a payload against it before you render.
Fetch the schema
GET /v1/templates/:id/schema returns the JSON Schema for a template you own. It is also embedded as json_schema on GET /v1/templates/:id and in the MCP get_template response.
curl https://doclinth.com/v1/templates/tmpl_…/schema \
-H "Authorization: Bearer dl_live_YOUR_KEY"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"invoice_number": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"amount": { "type": "number" },
"description": { "type": "string" }
},
"required": ["amount", "description"]
}
},
"logo": { "type": "string", "format": "uri" },
"total": { "type": "number" }
},
"required": ["invoice_number", "items", "total"]
}Keys are sorted, so the schema is deterministic and diffs are meaningful. A leaf used in an <img src> or href is annotated format: "uri"; a value a helper interprets rather than prints (a currency code, a barcode payload) is annotated format: "code".
Generate types
Add ?format=typescript, ?format=zod, or ?format=pydantic to get generated source text instead of JSON Schema — paste it into your codebase and let the compiler hold you to the contract.
curl "https://doclinth.com/v1/templates/tmpl_…/schema?format=typescript" \
-H "Authorization: Bearer dl_live_YOUR_KEY"export type TemplateData = {
invoice_number: string;
items: {
amount: number;
description: string;
}[];
logo?: string;
total: number;
};The same template as a Pydantic model (?format=pydantic):
from pydantic import BaseModel
class TemplateData(BaseModel):
invoice_number: str
items: list[dict[str, object]]
logo: str | None = None
total: floatFrom the SDKs
Both official SDKs expose the schema endpoint directly.
// Node — @doclinth/sdk
const schema = await doclinth.schema("tmpl_…"); // JSON Schema (object)
const types = await doclinth.schema("tmpl_…", "typescript"); // generated source (string)# Python — doclinth
schema = client.templates.schema("tmpl_…") # dict
types = client.templates.schema("tmpl_…", format="zod") # strValidate before rendering
Pass options.validate: true to POST /v1/generate and doclinth checks data against the contract before it renders. A payload that violates the schema returns 422 invalid_template_data with field-level errors — clearer, and cheaper, than discovering a blank field in the finished PDF. (Extra keys your template ignores are allowed; validation only enforces what the template actually references.)
curl -X POST https://doclinth.com/v1/generate \
-H "Authorization: Bearer dl_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tmpl_…",
"data": { "invoice_number": "INV-1042" },
"options": { "validate": true }
}'{
"error": {
"code": "invalid_template_data",
"message": "Data does not match the template contract.",
"fields": [
{ "path": "items", "message": "is required" },
{ "path": "total", "message": "is required" }
]
}
}Named fixtures
Fixtures are saved, named example payloads for a template — your own edge cases, kept alongside the auto-generated ones. Each carries optional expectations that document tests check on every run. Publishing a template auto-seeds a sample fixture from its sample data, so every template starts with a baseline.
| Method & path | Does |
|---|---|
GET /v1/templates/:id/fixtures | List every fixture for a template. |
POST /v1/templates/:id/fixtures | Create one: { "name", "data", "expectations"? }. A duplicate name returns 409 fixture_name_taken. |
PUT /v1/templates/:id/fixtures/:name | Replace a fixture's data and expectations. |
DELETE /v1/templates/:id/fixtures/:name | Remove a fixture. |
expectations is small on purpose — cheap to check from the rendered PDF, hard to get wrong:
curl -X POST https://doclinth.com/v1/templates/tmpl_…/fixtures \
-H "Authorization: Bearer dl_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "60-line-order",
"data": { "invoice_number": "INV-9001", "items": [ /* … */ ], "total": 41999 },
"expectations": {
"page_count": { "min": 2, "max": 3 },
"must_contain": ["INV-9001"],
"must_not_contain": ["{{"],
"max_render_ms": 8000
}
}'Breaking-change warnings
When you publish, doclinth diffs the new schema against the published one and warns before a change can break existing API callers. It flags fields that were removed or retyped, and — when a leaf disappears and one with the same tail name appears elsewhere — suggests the likely rename (customer_name → customer.name). Warnings never block a publish; they let you migrate callers deliberately.
Next
- Document tests: stress every contract with adversarial payloads and fixture expectations.
- Generate a PDF: the full
optionsreference, includingvalidate. - /openapi.json: the machine-readable contract for every endpoint above.