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: float

From 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")  # str

Validate 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 & pathDoes
GET /v1/templates/:id/fixturesList every fixture for a template.
POST /v1/templates/:id/fixturesCreate one: { "name", "data", "expectations"? }. A duplicate name returns 409 fixture_name_taken.
PUT /v1/templates/:id/fixtures/:nameReplace a fixture's data and expectations.
DELETE /v1/templates/:id/fixtures/:nameRemove 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_namecustomer.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 options reference, including validate.
  • /openapi.json: the machine-readable contract for every endpoint above.