Templates and create-from-template
Building a template, filling its roles, prefilling fields, excluding placeholders, composing several templates, and replacing the file without redrawing fields.
Updated
On this page
A template holds a file, a set of roles, the fields belonging to those roles, and the document settings. Creating a document from one is a matter of saying who fills each role.
Creating a template
{
"name": "Onboarding agreement",
"draft": true,
"text_tags": true,
"files": [{ "name": "onboarding.pdf", "file_url": "https://files.example.com/onboarding.pdf" }],
"placeholders": [{ "id": "borrower", "name": "Borrower" }],
"fields": [[
{ "api_id": "borrower_signature", "type": "signature", "placeholder_id": "borrower",
"page": 1, "x": 375, "y": 206, "width": 180, "height": 44 }
]]
}
draft: true returns an embedded_edit_url that opens the template builder, so a person can place fields visually rather than you computing coordinates. PUT /document_templates/{id} with { "draft": false } publishes it, and unlike the create it answers the settled status.
Statuses: the create answers the transient Created; a later GET reads Draft or Available. Only an Available template can be used.
Field coordinates are API units — CSS pixels at 96 DPI, one unit being 0.75 PDF points, origin at the top left of the page. The same units the document API uses.
Using a template
{
"template_id": "3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b",
"draft": false,
"embedded_signing": true,
"recipients": [
{ "id": "signer-1", "placeholder_name": "Borrower",
"name": "Jane Doe", "email": "[email protected]", "send_email": false }
],
"exclude_placeholders": ["Co-borrower"],
"template_fields": [
{ "api_id": "borrower_full_name", "value": "Jane Doe" },
{ "api_id": "borrower_start_date", "value": "2026-07-02T00:00:00Z" }
],
"conditional_rules_mode": "append",
"skip_invalid_fields": true,
"metadata": { "loan_id": "48213" }
}
Send it to POST /document_templates/documents:
curl -X POST https://api.signclad.com/api/v1/document_templates/documents \
-H "X-Api-Key: $SIGNCLAD_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"template_id": "3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b",
"draft": false,
"recipients": [
{ "id": "signer-1", "placeholder_name": "Borrower",
"name": "Jane Doe", "email": "[email protected]" }
],
"template_fields": [{ "api_id": "borrower_full_name", "value": "Jane Doe" }]
}'
import { randomUUID } from 'node:crypto';
const response = await fetch(
'https://api.signclad.com/api/v1/document_templates/documents',
{
method: 'POST',
headers: {
'X-Api-Key': process.env.SIGNCLAD_API_KEY,
'Idempotency-Key': randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
template_id: '3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b',
draft: false,
recipients: [
{ id: 'signer-1', placeholder_name: 'Borrower', name: 'Jane Doe', email: '[email protected]' },
],
template_fields: [{ api_id: 'borrower_full_name', value: 'Jane Doe' }],
}),
},
);
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
const document = await response.json();
import os
import uuid
import requests
response = requests.post(
"https://api.signclad.com/api/v1/document_templates/documents",
headers={
"X-Api-Key": os.environ["SIGNCLAD_API_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"template_id": "3f1c9a2e-6b7d-4c8e-9a0b-1c2d3e4f5a6b",
"draft": False,
"recipients": [
{
"id": "signer-1",
"placeholder_name": "Borrower",
"name": "Jane Doe",
"email": "[email protected]",
}
],
"template_fields": [{"api_id": "borrower_full_name", "value": "Jane Doe"}],
},
timeout=30,
)
response.raise_for_status()
document = response.json()
Points worth knowing:
placeholder_namebinds a recipient to a role. Every field belonging to that role follows.exclude_placeholdersdrops a role by name, and its fields come off the document. This is how one template covers the single-signer and the co-signer case.template_fieldsprefills byapi_id. Dates accept both ISO 8601 and bareY-m-d; anything else isinvalid_date_format, keyed by theapi_idthat failed.conditional_rules_modeisreplace,append, ornone.skip_invalid_fields: truedrops entries that will not validate and lists them inwarnings[]— astemplate_field_skipped— instead of failing the whole request. Without it, one bad value fails everything.fileson this call are appended to the template’s own files, never a replacement for them.- The response status is the transient
Draft. Read it back for the settled value.
Composing several templates
Pass template_ids instead of template_id:
- Files concatenate in the order given.
- Placeholders with the same name merge into one recipient, so a packet of four templates is one signing session.
- Settings come from the first template unless the request overrides them.
- Field identity is the pair (source template,
api_id). Every field in the response carriestemplate_id, and conditional rules and checkbox groups resolve within their own source template. template_fieldsaccepts{api_id, value}while theapi_idis unique across the set, and requires{template_id, api_id, value}when it is not. An ambiguous bare reference is a422withambiguous_field_referenceand the candidates listed.
Duplicating
POST /document_templates/{id}/duplicate
{ "name": "Onboarding agreement (2027)" }
Copies the whole template — fields, coordinates, rules, groups, settings — and returns the new one. No reading and rebuilding.
Template links
Publishing a template as a link gives you a public URL anyone can open to start their own copy. Each visitor’s copy is a separate document with its own recipients, its own audit trail, and its own sealed file. The settings live on their own prefix — GET and PUT /template_links/{template} — and the feature starts on Light.
POST /template_links/{template}/rotate replaces the token, which is the only way to revoke a published link: the old URL stops working immediately, everywhere it was printed or pasted. Copies already in flight are ordinary documents and are unaffected.
Replacing the file, keeping the fields
When the underlying document changes — legal edits a clause, the numbering shifts — you do not have to redraw the fields.
PUT /document_templates/{id}/files/1
{
"name": "loan-agreement-v2.pdf",
"file_url": "https://files.example.com/loan-agreement-v2.pdf",
"strategy": "anchors",
"scale_to_page": true,
"dry_run": true
}
| Strategy | Behaviour |
|---|---|
keep_positions |
Fields stay at the same coordinates. Right when only wording changed within lines. |
text_tags |
Fields are re-found from the tags in the new file. Right when the file is generated from a source that carries tags. |
anchors |
Each field is anchored to the text beside it and follows that text. Right when the layout moved. |
anchors is the default — it is the only strategy that works on fields nobody tagged. scale_to_page defaults to true. Send exactly one of file_url, file_base64, or file_id.
A dry run returns a report and changes nothing:
{
"revision": 7,
"dry_run": true,
"requires_review": true,
"report": {
"kept": [{ "api_id": "borrower_signature", "page": 1, "x": 375, "y": 206 }],
"moved": [{ "api_id": "borrower_date", "page": 1, "x": 375, "y": 289,
"previous_page": 1, "previous_x": 375, "previous_y": 262,
"reason": "Anchor text moved down 27 points." }],
"scaled": [],
"orphaned": [{ "api_id": "witness_initials", "previous_page": 5,
"reason": "Page 5 does not exist in the new file." }]
}
}
A field is never deleted by a replacement. One with nowhere to go is reported orphaned and left where it was, so a person can move it. requires_review is true when anything orphaned, or when a field travelled more than about half an inch.
Commit by repeating the call with dry_run: false and expected_revision set to the revision the dry run reported. If the template changed in between, the commit fails with 409 revision_mismatch rather than applying a stale plan. Leaving expected_revision out means “apply this to whatever the template is now”, which is the race the two-step design exists to prevent.
The same endpoint exists for drafts at PUT /documents/{id}/files/{position}. It never applies to a sent document.
Listing and searching
GET /document_templates?q=onboarding&archived=false&limit=50&cursor=...
Cursor-paginated, newest first, with the same filter vocabulary as documents: status, label, updated_since, metadata, q, archived, test_mode. limit caps at 100.
Revisions have their own pair: GET /document_templates/{id}/revisions lists them and POST /document_templates/{id}/revisions/{number}/restore puts one back.