API & webhooks
Send emails and render PDFs from your backend with the templates you publish in Templater, then get told about the result by webhook. The machine-readable spec is at /docs (OpenAPI: /openapi.json).
Overview
Base URL: https://templater.nestutils.com/v1. Requests and responses are JSON (Content-Type: application/json, up to 12 MB per request). Every id is a 26-character ULID.
Work happens asynchronously. An email send or PDF render is accepted at once (202) and processed by workers; you learn the result by webhook, by polling, or — for PDFs — by asking the request to wait up to 25 seconds.
- Publish a template in the app (Emails or PDFs). Its slug is how the API refers to it.
- Create an API key under API & keys with the scopes you need.
- Call the endpoint with the template slug and your data.
- Optionally add a webhook to be told when PDFs are ready or emails are delivered.
Authentication
Send your key as a bearer token. Keys look like tpl_live_… or tpl_test_… and are shown once, when created — store them as a secret on your server, never in a browser or mobile app.
curl https://templater.nestutils.com/v1/messages/01J8Z6K2QWX9M3T5V7B1C4D6E8 \ -H "Authorization: Bearer tpl_live_…"
| Field | Type | Description |
|---|---|---|
send | scope | Send emails, email batches, resend. |
render | scope | Render PDFs, read PDF status, PDF batches. |
preview | scope | Render a template without delivering it. |
send + render | both | Uploads and the /v1/batches endpoints need both scopes. |
Live and test keys
| Field | Type | Description |
|---|---|---|
tpl_live_… | live | Real sends. Uses published versions only: the draft is refused (403) whether asked for as "version": "draft" or by its version number, for attachments too. Needs a verified sending domain and SMTP. |
tpl_test_… | test | Every send is a test send: recipients must be members of your workspace (otherwise 422 TEST_KEY_CANNOT_SEND_REAL). May use drafts. Needs SMTP only. |
401 (UNAUTHENTICATED / API_KEY_REVOKED). A key without a required scope → 403 FORBIDDEN with details.code: "API_KEY_SCOPE_MISSING" and the missing scopes in details.missing.Responses & errors
Successful responses wrap the result in data; lists add meta.nextCursor (pass it back as ?cursor=, with ?limit= 1–100). Errors use one shape everywhere. traceId matches the x-request-id response header (send your own x-request-id to set it) — quote it when asking for help.
{
"data": { … },
"meta": { "traceId": "01M3CDRE8XPRTYJ24SDBDWN8DS" }
}{
"error": {
"code": "MISSING_REQUIRED_VARIABLES",
"message": "invoice-pdf needs 1 more variable(s).",
"details": { "template": "invoice-pdf", "missing": ["invoice.number"] },
"traceId": "01M3CDRE8XPRTYJ24SDBDWN8DS"
}
}Unknown fields in a request body are rejected with 400 VALIDATION_FAILED; details.fields lists each invalid field and why. All codes are listed under Error codes.
Idempotency
Networks fail; retries shouldn't send twice. Add an Idempotency-Key header to any POST and repeat the exact same request safely for 24 hours: the first response (body and status) is returned again and nothing runs twice.
409 IDEMPOTENCY_KEY_REUSED; a repeat while the first request is still running also gets a 409 — retry a few seconds later. Failed requests are not stored, so they can be retried with the same key after 30 seconds.# Derive the key from YOUR record, e.g. the invoice number.
# Same key + same body within 24 h → the original response is replayed.
# Same key + different body → 409 IDEMPOTENCY_KEY_REUSED.
curl -X POST https://templater.nestutils.com/v1/pdfs/render \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Idempotency-Key: invoice-KA-26-27-09-77" \
-H "Content-Type: application/json" \
-d '{ "template": "invoice-pdf", "data": { … } }'Endpoints
Send an email
/v1/emails/sendKey scope: sendRenders an email template with your data and delivers it through your workspace's SMTP. Returns 202 once queued.
| Field | Type | Description |
|---|---|---|
templaterequired | string | Slug of an email template (max 64). |
version | "live" | "draft" | number | Defaults to "live" (the published version). |
torequired | string[] | 1–50 email addresses. |
cc / bcc | string[] | Up to 50 each. |
datarequired | object | Values for the template’s variables. Required variables are checked (422 MISSING_REQUIRED_VARIABLES). |
attachments | { template, version? }[] | Up to 5 PDF templates, rendered with the same data and attached. |
scheduledFor | ISO 8601 | Send later instead of now (checked every minute). |
curl -X POST https://templater.nestutils.com/v1/emails/send \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Idempotency-Key: invoice-INV-2026-0142-issued" \
-H "Content-Type: application/json" \
-d '{
"template": "invoice-issued",
"to": ["priya@example.com"],
"data": { "customer": { "first_name": "Priya" }, "invoice": { "number": "INV-2026-0142", "total": 48250 } },
"attachments": [{ "template": "invoice-pdf" }]
}'HTTP/1.1 202 Accepted
{
"data": {
"id": "01M3CDJKX8RWAYVCGWRJ2H5MRW",
"status": "queued",
"attachments": ["invoice-pdf"],
"scheduledFor": null
},
"meta": { "traceId": "01M3CDRE8XPRTYJ24SDBDWN8DS" }
}Follow it with GET /v1/messages/:id or the email.delivered, email.bounced and render.failed webhooks.
Render a PDF
/v1/pdfs/renderKey scope: renderRenders a PDF template. Each call creates a new render with its own id. Without wait it answers 202 immediately; with ?wait=N (0–25 seconds) it holds the request and answers 200 with the file if it's ready in time, or 202 if not.
| Field | Type | Description |
|---|---|---|
templaterequired | string | Slug of a PDF template (max 64). |
version | "live" | "draft" | number | Defaults to "live". |
datarequired | object | Values for the template’s variables. |
returnAs | "signed_url" | "base64" | "binary" | How a ready PDF comes back. Defaults to the template’s output setting (signed_url). binary returns the raw application/pdf bytes, without the JSON envelope. |
fileName | string | Overrides the template’s file-name pattern. Must end in .pdf, no slashes (max 200). |
| Field | Type | Description |
|---|---|---|
wait | integer | Seconds to wait for the PDF, 0–25. Default 0. |
curl -X POST "https://templater.nestutils.com/v1/pdfs/render?wait=20" \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Idempotency-Key: invoice-KA-26-27-09-77" \
-H "Content-Type: application/json" \
-d '{
"template": "invoice-pdf",
"fileName": "KA-26-27-09-77.pdf",
"data": { "invoice_number": "KA/26-27/09/77", "items": [ … ] }
}'HTTP/1.1 200 OK (rendered within ?wait)
{
"data": {
"id": "01M3CDJKX8RWAYVCGWRJ2H5MRW",
"fileName": "KA-26-27-09-77.pdf",
"pageCount": 2,
"sizeBytes": 184233,
"returnAs": "signed_url",
"url": "https://…/KA-26-27-09-77-01M3CDJKX8….pdf?X-Amz-Signature=…",
"expiresAt": "2026-09-26T13:05:12.000Z",
"pdfBase64": null
},
"meta": { "traceId": "…" }
}HTTP/1.1 202 Accepted (still rendering)
{
"data": {
"id": "01M3CDJKX8RWAYVCGWRJ2H5MRW",
"status": "queued",
"failureReason": null,
"result": null
},
"meta": { "traceId": "…" }
}url is valid until expiresAt — 24 hours after rendering. Download and store the file if you need it longer. When a render isn't ready, get it later with GET /v1/pdfs/:id or the pdf.rendered webhook, which carries the same link.Get a PDF
/v1/pdfs/:idKey scope: renderThe render's status — queued, then rendered or failed — and, once rendered, the file with its signed link in result. failureReason explains a failure.
curl https://templater.nestutils.com/v1/pdfs/01M3CDJKX8RWAYVCGWRJ2H5MRW \
-H "Authorization: Bearer $TEMPLATER_KEY"
# → { "data": { "id": "…", "status": "rendered", "failureReason": null,
# "result": { "fileName": "…", "url": "https://…", "expiresAt": "…", … } } }Bulk: many emails or PDFs from a CSV
/v1/uploadsKey scope: send + render/v1/emails/batchKey scope: send/v1/pdfs/batchKey scope: render/v1/batches/:idKey scope: send + render/v1/batchesKey scope: send + render/v1/batches/:id/cancelKey scope: send + renderOne row per email or PDF. Give the rows as exactly one of: uploadId (a CSV uploaded through /v1/uploads, up to 50 MB / 100,000 rows), csv (inline text, up to 1,000,000 characters) or rows (JSON, up to 5,000 of { to?, data }). PDF batches bundle the files into a ZIP (zip, default true).
# 1. Ask for an upload slot (valid 15 minutes)
curl -X POST https://templater.nestutils.com/v1/uploads \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Content-Type: application/json" \
-d '{ "fileName": "invoices.csv", "bytes": 1048576 }'
# → { "data": { "uploadId": "01M3…", "url": "https://…", "method": "PUT",
# "headers": { "content-type": "text/csv", … }, "expiresAt": "…" } }
# 2. PUT the file to that URL with exactly those headers
curl -X PUT "<url>" -H "content-type: text/csv" --data-binary @invoices.csv
# 3. Start the batch
curl -X POST https://templater.nestutils.com/v1/pdfs/batch \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Content-Type: application/json" \
-d '{ "template": "invoice-pdf", "uploadId": "01M3…", "zip": true }'
# 4. Follow it
curl https://templater.nestutils.com/v1/batches/<batchId> -H "Authorization: Bearer $TEMPLATER_KEY"| Field | Type | Description |
|---|---|---|
header row | required | Column names are data paths: invoice.number becomes { invoice: { number } }. |
to | column | Required for email batches. Several addresses separated by ; or ,. |
JSON cells | […] or {…} | A cell starting with [ or { is parsed as JSON — use it for line items. |
numbers / booleans | auto | 42 and 3.5 become numbers (not 07012 — leading zeros stay text); true / false become booleans. |
bad rows | skipped | Rows with an invalid address or missing required values are skipped, counted in counters.invalid and listed in errorsUrl (row,error). |
to,customer.first_name,invoice.number,invoice.items
priya@example.com,Priya,INV-0142,"[{""name"":""Widget"",""qty"":2}]"
arjun@example.com,Arjun,INV-0143,"[{""name"":""Gadget"",""qty"":1}]"{
"data": {
"id": "01M3CE…",
"kind": "pdf",
"status": "running",
"templateSlug": "invoice-pdf",
"versionRef": "live",
"isTest": false,
"fileName": "invoices.csv",
"columns": ["to", "customer.first_name", "invoice.number", "invoice.items"],
"counters": { "rows": 1000, "valid": 998, "invalid": 2, "succeeded": 640, "failed": 0, "pending": 358 },
"errorsUrl": "https://…/errors.csv?…",
"zipUrl": null,
"failureReason": null,
"createdAt": "2026-09-25T13:00:00.000Z",
"finishedAt": null
}
}errorsUrl and zipUrl are signed links valid for one hour — fetch the batch again for a fresh one. Cancelling stops rows that haven't started; emails already handed to SMTP stay sent.
Messages
/v1/messages/:idKey scope: any/v1/messages/:id/resendKey scope: sendEvery send and render is a message. GET returns its status, recipients, the timeline of events ({ label, note, at }), the files it produced (renders) and the data it was sent with. Resend creates a new email with the same template, recipients and data (emails only).
Preview
/v1/templates/:slug/previewKey scope: previewRenders a template with your data and returns the result, without sending, storing or recording anything — use it to check a template or show a preview in your own app. Body: data, the values the template uses, shaped like its sample data (leave it out to use the sample); version ("live" by default, "draft" or a version number, as a string); and for PDF templates output (html for the print document, pdf for the file). Emails return subject, preheader, html and text; PDFs return pdfBase64 or html. missingVariables lists required values your data lacks.
curl -X POST https://templater.nestutils.com/v1/templates/invoice-issued/preview \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": "draft",
"data": {
"customer": { "first_name": "Priya" },
"invoice": { "number": "INV-0142", "total": 48250, "currency": "INR" }
}
}'HTTP/1.1 200 OK
{
"data": {
"type": "email",
"versionNumber": 5,
"missingVariables": [],
"subject": "Invoice INV-0142 from Lumen Supply Co.",
"preheader": "Due 05 Oct 2026",
"html": "<!doctype html><html>… Hi Priya …</html>",
"text": "Hi Priya, invoice INV-0142 for ₹48,250.00 …"
},
"meta": { "traceId": "01M3CDJ9…" }
}The JSON response escapes the HTML (quotes become \", line breaks \n) like any JSON string. To get the HTML itself — to save it, open it in a browser or paste it into an editor — send Accept: text/html; for a PDF template, Accept: application/pdf returns the file.
# The HTML itself, ready to open or paste — no JSON escaping:
curl -X POST https://templater.nestutils.com/v1/templates/invoice-issued/preview \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/html" \
-d '{ "data": { "customer": { "first_name": "Priya" } } }' > invoice.html
# A PDF template as the file:
curl -X POST https://templater.nestutils.com/v1/templates/invoice-pdf/preview \
-H "Authorization: Bearer $TEMPLATER_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/pdf" \
-d '{ "data": { "invoice": { "number": "INV-0142" } } }' > invoice.pdfdata — fields at the top level are refused. Live keys preview published versions only; use a test key for "draft".Statuses
| Field | Type | Description |
|---|---|---|
scheduled | Waiting for scheduledFor. | |
queued | both | Accepted; a worker is rendering it. |
rendered | both | Content produced. Final for PDFs (pdf.rendered). |
handed_off | Being handed to your SMTP server. | |
delivered | Accepted by your SMTP server (email.delivered). | |
opened | Opened by a recipient (email.opened). | |
bounced | Rejected by the SMTP server (email.bounced). | |
failed | both | Gave up after retries; see failureReason (render.failed). |
Batch status: pending → splitting → ingesting → running → (bundling) → completed, or cancelled / failed.
Webhooks
Webhooks push events to your server as they happen, so you don't have to poll. Add an endpoint under Webhooks, choose its events, and keep the signing secret (whsec_…) it shows you once. "Send test" delivers a ping event so you can check your endpoint; every attempt is listed under the webhook's deliveries.
Events
| Field | Type | Description |
|---|---|---|
pdf.rendered | A PDF is ready. data.message.renders[0].url is a signed download link. | |
email.delivered | Your SMTP server accepted the email. | |
email.bounced | Your SMTP server rejected it permanently. | |
email.opened | A recipient opened it. | |
render.failed | both | A PDF or email failed for good. data.reason says why. |
Payload
Every event has the same envelope. data.message describes the message; it never includes the data you sent, which may be personal. For PDFs and emails with attachments, renders lists each file with a signed url valid until expiresAt (24 hours after rendering); if it has expired, call GET /v1/pdfs/:id.
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Templater-Webhooks/1
Templater-Signature: t=1790341512,v1=5f2b0c…e91a
{
"id": "evt_01M3CDS0Q3N6G9WJ4Z7R2K8B5T",
"type": "pdf.rendered",
"createdAt": "2026-09-25T13:05:12.402Z",
"data": {
"message": {
"id": "01M3CDJKX8RWAYVCGWRJ2H5MRW",
"kind": "pdf",
"status": "rendered",
"isTest": false,
"templateSlug": "invoice-pdf",
"versionNumber": 3,
"to": [],
"idempotencyKey": null,
"renders": [
{
"role": "primary",
"fileName": "KA-26-27-09-77.pdf",
"pageCount": 2,
"sizeBytes": 184233,
"url": "https://…/KA-26-27-09-77-01M3CDJKX8….pdf?X-Amz-Signature=…",
"expiresAt": "2026-09-26T13:05:10.000Z"
}
],
"createdAt": "2026-09-25T13:05:04.118Z"
}
}
}{
"id": "evt_…",
"type": "render.failed",
"createdAt": "…",
"data": {
"message": { "id": "…", "kind": "pdf", "status": "failed", … },
"reason": "Template error: \"items\" is not iterable"
}
}Verifying signatures
Each request carries Templater-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw request body>" keyed with your signing secret (the whole whsec_… string). Recompute it over the raw bytes, compare in constant time, and reject timestamps more than 5 minutes old. Rotating the secret retires the old one immediately.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.TEMPLATER_WEBHOOK_SECRET; // whsec_…
// Verify against the RAW body — re-serialised JSON will not match.
app.post('/webhooks/templater', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Templater-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
const valid = parts.v1 && expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!fresh || !valid) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, then do the work asynchronously.
if (event.type === 'pdf.rendered') {
const [file] = event.data.message.renders;
console.log('PDF ready', file.fileName, file.url);
}
res.sendStatus(200); // answer fast: any 2xx within 10 s counts as delivered
});import hmac, hashlib, time, json, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["TEMPLATER_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/webhooks/templater")
def templater():
raw = request.get_data() # the raw bytes
parts = dict(p.split("=", 1) for p in request.headers.get("Templater-Signature", "").split(","))
t = int(parts.get("t", "0"))
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if abs(time.time() - t) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = json.loads(raw)
# Deduplicate on event["id"], then do the work asynchronously.
return "", 200<?php
$secret = getenv('TEMPLATER_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_TEMPLATER_SIGNATURE'] ?? ''), $parts);
$t = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// Deduplicate on $event['id'], then do the work asynchronously.
http_response_code(200);Delivery, retries and ordering
- Answer with any
2xxwithin 10 seconds. Do slow work after responding. - Anything else — a non-2xx, a timeout, a network error, or a redirect (redirects are not followed; use the final
httpsURL) — is retried: after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h (7 attempts over about 21 hours). Each retry sends the same body and eventidwith a fresh signature. - An endpoint that keeps failing for 24 hours is turned off and workspace owners are emailed. Turn it back on from API & keys.
- Events can arrive more than once and out of order. Deduplicate on the event
idand usedata.message.statusas the source of truth. - A PDF event normally arrives a few seconds after the render is accepted; the first render after a quiet period can take longer.
Error codes
| Field | Type | Description |
|---|---|---|
VALIDATION_FAILED | 400 / 422 | A field is invalid (details.fields), or the template is the wrong type for the endpoint (422). |
UNAUTHENTICATED | 401 | No credentials. |
API_KEY_REVOKED | 401 | The key is unknown or revoked. |
FORBIDDEN | 403 | Missing scope (details.code API_KEY_SCOPE_MISSING), or a live key asked for a draft. |
NOT_FOUND / TEMPLATE_NOT_FOUND | 404 | No such message, render or template in this workspace. |
IDEMPOTENCY_KEY_REUSED | 409 | Key reused with a different body, or the first request is still running. |
NO_LIVE_VERSION | 422 | The template has never been published. |
MISSING_REQUIRED_VARIABLES | 422 | data lacks required variables; details.missing lists them. |
ATTACHMENT_TEMPLATE_NOT_PDF | 422 | An attachment isn’t a PDF template. |
LAYOUT_NOT_SENDABLE | 422 | The slug is a layout — a frame templates are built on. Send a template built on it. |
TEST_KEY_CANNOT_SEND_REAL | 422 | A test key tried to send to someone outside the workspace. |
DOMAIN_NOT_VERIFIED | 422 | Real sends need a verified sending domain (Settings). |
SMTP_NOT_CONFIGURED | 422 | Add SMTP in Settings before sending. |
CSV_INVALID | 422 | Give exactly one of uploadId, csv or rows. |
UPLOAD_NOT_FOUND | 422 | The uploadId is unknown or the file was never PUT. |
BATCH_FINISHED | 422 | The batch already completed, failed or was cancelled. |
INVALID_STATE | 422 | The action doesn’t apply now (e.g. resending a PDF). |
RATE_LIMITED | 429 | Too many requests; wait for Retry-After seconds. |
INTERNAL | 500 | Our side. Retry with the same Idempotency-Key; quote the traceId if it persists. |
Limits
| Field | Type | Description |
|---|---|---|
Rate limit | 300 / min | Per client IP. X-RateLimit-* headers show what is left; 429 includes Retry-After. |
Request body | 12 MB | Includes inline images in data (e.g. base64 QR codes). |
Recipients | 50 | Per field (to, cc, bcc) per email. |
Attachments | 5 | PDF templates per email. |
PDF wait | 25 s | Maximum ?wait on /v1/pdfs/render. |
Signed PDF links | 24 h | From rendering. Batch errorsUrl / zipUrl: 1 h. Upload URLs: 15 min. |
CSV upload | 50 MB | Up to 100,000 rows. Inline csv: 1,000,000 characters. JSON rows: 5,000. |