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.

  1. Publish a template in the app (Emails or PDFs). Its slug is how the API refers to it.
  2. Create an API key under API & keys with the scopes you need.
  3. Call the endpoint with the template slug and your data.
  4. 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.

Authorization header
curl https://templater.nestutils.com/v1/messages/01J8Z6K2QWX9M3T5V7B1C4D6E8 \
  -H "Authorization: Bearer tpl_live_…"
Scopes — a key can call an endpoint only if it has every scope that endpoint lists
FieldTypeDescription
sendscopeSend emails, email batches, resend.
renderscopeRender PDFs, read PDF status, PDF batches.
previewscopeRender a template without delivering it.
send + renderbothUploads and the /v1/batches endpoints need both scopes.

Live and test keys

FieldTypeDescription
tpl_live_…liveReal 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_…testEvery 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.
Missing or revoked key → 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.

Success
{
  "data": { … },
  "meta": { "traceId": "01M3CDRE8XPRTYJ24SDBDWN8DS" }
}
Error
{
  "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.

Use a new key for every distinct request. Derive it from your own record — an invoice number, an order id plus the event — never a fixed string. Reusing a key with a different body is refused with 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.
A safe retry
# 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

POST/v1/emails/sendKey scope: send

Renders an email template with your data and delivers it through your workspace's SMTP. Returns 202 once queued.

FieldTypeDescription
templaterequiredstringSlug of an email template (max 64).
version"live" | "draft" | numberDefaults to "live" (the published version).
torequiredstring[]1–50 email addresses.
cc / bccstring[]Up to 50 each.
datarequiredobjectValues 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.
scheduledForISO 8601Send later instead of now (checked every minute).
Request
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" }]
  }'
Response
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

POST/v1/pdfs/renderKey scope: render

Renders 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.

Body
FieldTypeDescription
templaterequiredstringSlug of a PDF template (max 64).
version"live" | "draft" | numberDefaults to "live".
datarequiredobjectValues 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.
fileNamestringOverrides the template’s file-name pattern. Must end in .pdf, no slashes (max 200).
Query
FieldTypeDescription
waitintegerSeconds to wait for the PDF, 0–25. Default 0.
Request
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": [ … ] }
  }'
Ready
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": "…" }
}
Not ready yet
HTTP/1.1 202 Accepted   (still rendering)

{
  "data": {
    "id": "01M3CDJKX8RWAYVCGWRJ2H5MRW",
    "status": "queued",
    "failureReason": null,
    "result": null
  },
  "meta": { "traceId": "…" }
}
The signed 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

GET/v1/pdfs/:idKey scope: render

The render's status — queued, then rendered or failed — and, once rendered, the file with its signed link in result. failureReason explains a failure.

Poll
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

POST/v1/uploadsKey scope: send + render
POST/v1/emails/batchKey scope: send
POST/v1/pdfs/batchKey scope: render
GET/v1/batches/:idKey scope: send + render
GET/v1/batchesKey scope: send + render
POST/v1/batches/:id/cancelKey scope: send + render

One 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).

Upload a CSV and generate PDFs
# 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"
CSV rules
FieldTypeDescription
header rowrequiredColumn names are data paths: invoice.number becomes { invoice: { number } }.
tocolumnRequired 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 / booleansauto42 and 3.5 become numbers (not 07012 — leading zeros stay text); true / false become booleans.
bad rowsskippedRows with an invalid address or missing required values are skipped, counted in counters.invalid and listed in errorsUrl (row,error).
invoices.csv
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}]"
GET /v1/batches/:id
{
  "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

GET/v1/messages/:idKey scope: any
POST/v1/messages/:id/resendKey scope: send

Every 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

POST/v1/templates/:slug/previewKey scope: preview

Renders 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.

Request
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" }
    }
  }'
Response
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.

Raw HTML or PDF
# 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.pdf
Put the values inside data — fields at the top level are refused. Live keys preview published versions only; use a test key for "draft".

Statuses

Message status
FieldTypeDescription
scheduledemailWaiting for scheduledFor.
queuedbothAccepted; a worker is rendering it.
renderedbothContent produced. Final for PDFs (pdf.rendered).
handed_offemailBeing handed to your SMTP server.
deliveredemailAccepted by your SMTP server (email.delivered).
openedemailOpened by a recipient (email.opened).
bouncedemailRejected by the SMTP server (email.bounced).
failedbothGave 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

FieldTypeDescription
pdf.renderedPDFA PDF is ready. data.message.renders[0].url is a signed download link.
email.deliveredemailYour SMTP server accepted the email.
email.bouncedemailYour SMTP server rejected it permanently.
email.openedemailA recipient opened it.
render.failedbothA 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.

pdf.rendered
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"
    }
  }
}
render.failed
{
  "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.

Node (Express)
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
});
Python (Flask)
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
<?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 2xx within 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 https URL) — 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 event id with 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 id and use data.message.status as 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

FieldTypeDescription
VALIDATION_FAILED400 / 422A field is invalid (details.fields), or the template is the wrong type for the endpoint (422).
UNAUTHENTICATED401No credentials.
API_KEY_REVOKED401The key is unknown or revoked.
FORBIDDEN403Missing scope (details.code API_KEY_SCOPE_MISSING), or a live key asked for a draft.
NOT_FOUND / TEMPLATE_NOT_FOUND404No such message, render or template in this workspace.
IDEMPOTENCY_KEY_REUSED409Key reused with a different body, or the first request is still running.
NO_LIVE_VERSION422The template has never been published.
MISSING_REQUIRED_VARIABLES422data lacks required variables; details.missing lists them.
ATTACHMENT_TEMPLATE_NOT_PDF422An attachment isn’t a PDF template.
LAYOUT_NOT_SENDABLE422The slug is a layout — a frame templates are built on. Send a template built on it.
TEST_KEY_CANNOT_SEND_REAL422A test key tried to send to someone outside the workspace.
DOMAIN_NOT_VERIFIED422Real sends need a verified sending domain (Settings).
SMTP_NOT_CONFIGURED422Add SMTP in Settings before sending.
CSV_INVALID422Give exactly one of uploadId, csv or rows.
UPLOAD_NOT_FOUND422The uploadId is unknown or the file was never PUT.
BATCH_FINISHED422The batch already completed, failed or was cancelled.
INVALID_STATE422The action doesn’t apply now (e.g. resending a PDF).
RATE_LIMITED429Too many requests; wait for Retry-After seconds.
INTERNAL500Our side. Retry with the same Idempotency-Key; quote the traceId if it persists.

Limits

FieldTypeDescription
Rate limit300 / minPer client IP. X-RateLimit-* headers show what is left; 429 includes Retry-After.
Request body12 MBIncludes inline images in data (e.g. base64 QR codes).
Recipients50Per field (to, cc, bcc) per email.
Attachments5PDF templates per email.
PDF wait25 sMaximum ?wait on /v1/pdfs/render.
Signed PDF links24 hFrom rendering. Batch errorsUrl / zipUrl: 1 h. Upload URLs: 15 min.
CSV upload50 MBUp to 100,000 rows. Inline csv: 1,000,000 characters. JSON rows: 5,000.