Managing templates from a script
Everything the console's Templates page does is also a call on the
API, apart from renaming, so an integration can onboard itself — upload a
template, roll a new version forward, activate it — without anyone opening the
console. A template is renamed in the console (the template's page, Name):
only the name people read changes, the templateId your code calls
stays the same. The file
travels as base64 inside the JSON body, the same shape as every other
endpoint here, rather than multipart: nicer for callers like Salesforce Apex
that have no pleasant multipart story.
| Method | Path | Does |
|---|---|---|
| POST | /api/v1/compose/templates | Create a template + version 1, active immediately. |
| GET | /api/v1/compose/templates | List this tenant's templates. |
| GET | /api/v1/compose/templates/{templateId} | One template, with every version. |
| POST | /api/v1/compose/templates/{templateId}/versions | Add a version. Arrives inactive — same rule as the console. |
| POST | /api/v1/compose/templates/{templateId}/versions/{n}/activate | Activate version n. Others stay as they are. |
| POST | /api/v1/compose/templates/{templateId}/versions/{n}/deactivate | Switch version n off; the rest keep working. |
| POST | /api/v1/compose/templates/{templateId}/deactivate | Switch the whole template off, every version with it. |
POST /api/v1/compose/templates
{
"name": "Invoice",
"templateType": "docx",
"fileName": "invoice.docx",
"fileBase64": "UEsDBBQAAAAIA...",
"errorOnMissingFields": false
}
Every one of these answers with the template's full current state:
{
"templateId": "invoice-a1b2c3",
"name": "Invoice",
"templateType": "docx",
"active": true,
"errorOnMissingFields": false,
"payloadValidation": "warn",
"activeVersions": [1],
"versions": [
{ "versionNumber": 1, "active": true,
"originalFilename": "invoice.docx",
"checksum": "9f8c...", "createdAt": "2026-09-14T10:00:00+00:00",
"hasJsonSchema": false }
]
}
Validated at upload, not at the first generate. These
endpoints check the file immediately — the same check the console runs
when a file is uploaded there — and answer TEMPLATE_FILE_REJECTED or
TEMPLATE_FILE_INVALID on the upload call itself — an unattended
integration has no page to notice the problem on later.
templateType is fixed at creation (docx,
html or xlsx); every later version of that template
must be the same format. Template ids are assigned by the service, the same
slug-plus-suffix scheme the admin uses — do not try to choose one.
Checking the data: JSON Schema
A version can carry a jsonSchema (on the upload above, on a
new version, or pasted in the console). Every compose call that uses that
version then has its data measured against it. What a violation
does is the template's payloadValidation:
| Mode | What happens |
|---|---|
warn (default) | The document is produced and charged as usual. output.warnings carries PAYLOAD_SCHEMA_VIOLATIONS (2106) listing the first 20 violations. |
reject | The call is refused with PAYLOAD_SCHEMA_VIOLATION (1403, 422) before any credit, test item or render slot is taken: it costs nothing and is never recorded as the result of an Idempotency-Key. In a bulk batch the item fails with that code and is not charged; the rest go on. In a flow, nothing is composed or sent. |
off | The schema is ignored. |
Set the mode with payloadValidation when you create the
template, or on a new version together with its schema, so a CI job can ship
both in one call and switch to reject:
POST /api/v1/compose/templates/{templateId}/versions
{
"fileName": "invoice.docx",
"fileBase64": "UEsDBBQAAAAIA...",
"jsonSchema": {
"type": "object",
"required": ["cliente"],
"properties": {
"cliente": { "type": "string", "maxLength": 40 },
"importo": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"payloadValidation": "reject"
}
A violation is {path, keyword, message}: path is a
JSON pointer into your data (/righe/1/qta;
empty means the whole payload), keyword the schema keyword that
failed, message a sentence made of the schema's own words and the
type found. The value you sent is never in it, so the
warning is the same on a dataless organisation. A key name that came from your
data (under additionalProperties) is hidden there too.
The schema is customer input and is treated like a template: it is
code. The service enforces a bounded subset of draft 2020-12 and refuses the rest
at upload with JSON_SCHEMA_INVALID (1303), so a schema either does
what it says or is not accepted.
- Supported:
type,enum,const,minimum,maximum,exclusiveMinimum,exclusiveMaximum,multipleOf,minLength,maxLength,pattern,minItems,maxItems,uniqueItems,items(one schema),required,properties,additionalProperties,minProperties,maxProperties,allOf,anyOf,oneOf,not, and$refto#or#/$defs/name. - Accepted and ignored:
$schema,$id,$comment,title,description,default,examples,deprecated,readOnly,writeOnlyandformat(never asserted). - Refused: everything else (
patternProperties,prefixItems,if/then/else,dependentRequired,unevaluated*), and any$refthat is not local: nothing is ever fetched. - Limits: 64 KB of schema, 24 levels, 1,000 sub-schemas, 500 enum values; a
patternof at most 128 characters, no back-references or look-arounds, no repeated group that itself repeats, at most two unbounded repeats, and a string over 2,048 characters is not matched against a pattern. Checking has a step and time budget; if it runs out the answer is a violation with keyword$budget, never "valid".
Retrying an upload
Every POST on this page accepts an
Idempotency-Key header, and none of them charges you anything.
That is not the reason to send one. A timed-out upload retried without a key
leaves you with two templates of the same name and two ids,
and your integration then points at whichever came back last while somebody
in the console picks the other.
POST /api/v1/compose/templates
Authorization: Bearer lpdc_…
Idempotency-Key: invoice-template-v3
The rules are the ones on
composing a document, unchanged: the
same key with the same body replays the first answer with
Idempotent-Replay: true, the same key with a different body is
refused, and a failed attempt is never recorded so your next one runs for
real.