Template-Mode API — for external clients & LLM agents
SonarSend templates are MJML body documents with a typed schema —
parameters (typed values), sections (repeating typed groups), and slots
(pluggable content blocks). External clients fill in a template_binding,
preview the result, and create a broadcast. This doc walks through the
flow end-to-end with curl examples.
If you’re an LLM agent reading this: also fetch /api/discover for a
machine-readable index of every operation, then /api/openapi.json for
the full schema.
URL prefix + authentication#
Every tenant-scoped endpoint is prefixed with /t/{tenant_slug}/. Get
the slug from the admin UI URL (https://app.sonarsend.com/t/<slug>/...)
or from GET /t/<slug>/api/settings/account.
Auth is a tenant-scoped API key in the X-API-Key header:
X-API-Key: sonar_…
Get a key at /t/<slug>/api/auth/api-keys. Scopes:
templates:read— discovery, validation, preview renderbroadcasts:write— create / update / clone broadcastsbroadcasts:schedule— move broadcast to scheduled
The two tenant-agnostic endpoints — GET /api/discover and
GET /api/openapi.json — require no auth and no tenant prefix.
The flow#
1. List templates GET /t/{slug}/api/templates (lean shape)
2. Discover binding shape GET /t/{slug}/api/templates/{id_or_slug}/binding-options
3. Validate a partial binding POST /t/{slug}/api/templates/{id_or_slug}/validate-binding
4. Preview the rendered output POST /t/{slug}/api/templates/{id_or_slug}/render
5. Create broadcast POST /t/{slug}/api/broadcasts (Idempotency-Key supported)
6. Schedule it POST /t/{slug}/api/broadcasts/{id}/schedule
Steps 3 and 4 are optional — go straight from binding-options to
POST /t/{slug}/api/broadcasts if you have full confidence. Most LLM-driven
pipelines use 3 + 4 in a loop until valid.
List response shape (lean by default)#
GET /t/{slug}/api/templates returns a lean shape — id, slug, name,
format, schema counts, optional usage, timestamps. No content_html,
content_mjml, content_text, or full schema arrays. Keeps responses
small for LLM context windows + large catalogs.
[
{
"id": "187d6bd1-…",
"slug": "dog-ear-weekly",
"name": "Dog-Ear Weekly",
"format": "mjml",
"parameter_count": 10,
"section_count": 2,
"slot_count": 1,
"group_count": 6,
"created_at": "2026-04-21T…",
"updated_at": "2026-04-25T…"
}
]
Add ?include_usage=true to also get usage: { active_drafts, recent_broadcasts }
per row.
Pass ?full=true for the legacy fat shape (everything including bodies
and full schemas) — only use when you actually need every field on every
row.
GET /t/{slug}/api/blocks is similar — drops content_html, content_mjml,
content_text but keeps parameters_schema and sections_schema (the
broadcast composer’s slot picker needs them).
For a single template’s full payload, fetch GET /t/{slug}/api/templates/{id_or_slug}.
For complete binding-shape discovery (parameters + sections + slots
with eligible blocks), use binding-options below.
Identifiers: UUIDs and slugs#
Every template and content block has both:
- A UUID (immutable, globally unique).
- A slug (per-tenant unique, human-readable, e.g.
dog-ear-weekly).
Routes that take {id} accept either. Slot bindings can reference a
block via either block_id: <uuid> or block_id: <slug> — the server
resolves either to the same row.
# Both work:
curl https://app.sonarsend.com/t/acme/api/templates/dog-ear-weekly/binding-options \
-H "X-API-Key: $API_KEY"
curl https://app.sonarsend.com/t/acme/api/templates/187d6bd1-…-737bf1/binding-options \
-H "X-API-Key: $API_KEY"
1. Discover the binding shape#
curl https://app.sonarsend.com/t/{slug}/api/templates/dog-ear-weekly/binding-options \
-H "X-API-Key: $API_KEY"
Returns everything you need to construct a binding in one round trip:
{
"template_id": "187d6bd1-…",
"slug": "dog-ear-weekly",
"name": "Dog-Ear Weekly",
"parameters_schema": [
{
"name": "preview_text",
"type": "text",
"required": true,
"label": "Preview text",
"help_text": "Shows next to the subject in most inbox clients. Aim for ~80–110 characters.",
"validation": { "max_length": 150 },
"sample": "Pricing strategies that actually move the needle"
},
/* … */
],
"sections_schema": [
{
"name": "secondary_articles",
"label": "Also Worth Your Time",
"required": true,
"min": 1,
"max": 8,
"item_schema": [
{ "name": "headline", "type": "text", "required": true, "validation": { "max_length": 140 } },
{ "name": "url", "type": "url", "required": true },
{ "name": "description", "type": "rich_text", "required": true, "validation": { "max_length": 400 } },
{ "name": "cta", "type": "text", "required": true, "default": "Read more", "validation": { "max_length": 40 } }
]
}
],
"slots_schema": [
{
"name": "pitch_block",
"label": "Weekly pitch block",
"tag_filter": ["newsletter-pitch"],
"required": false,
"allowed_strategies": ["fixed", "random", "empty"],
"default_strategy": "fixed",
"help_text": "Pick one of the 5 pitch concepts.",
// ★ Eligible blocks pre-resolved against tag_filter — each carries its
// own parameters_schema so you know exactly what to bind.
"eligible_blocks": [
{
"slug": "pitch-go-deeper",
"name": "Pitch: Go Deeper",
"tags": ["newsletter-pitch"],
"parameters_schema": [
{ "name": "concept_topic", "type": "text", "required": true },
{ "name": "concept_resource", "type": "text", "required": true }
],
"sections_schema": []
}
],
"eligible_blocks_truncated": false,
"eligible_blocks_total": 5
}
],
"groups_schema": [/* visual grouping for UIs */]
}
eligible_blocks_truncated: true — the slot has more than 25
matching blocks. Call GET /t/{slug}/api/blocks?tags=<tag> for the full list.
How section item fields appear in template MJML#
sections_schema defines the binding shape — the rows you submit in a
binding’s sections.<name> array. The matching MJML inside the template
references each row’s fields with {{item:field_name}} inside a
{{#section name}}…{{/section}} block. For the secondary_articles
section above, the template MJML looks like:
{{#section secondary_articles}}
<mj-text><a href="{{item:url}}">{{item:headline}}</a></mj-text>
<mj-text>{{item:description}}</mj-text>
<mj-text>{{item:cta}}</mj-text>
{{/section}}
You don’t author this MJML when calling the API — it lives on the
template — but knowing the syntax helps you map binding rows to rendered
output. Bare {{headline}} inside a section body is treated as a
recipient merge field, not as an item ref (#189). Legacy templates
that used bare tokens for item refs were rewritten on import.
2. Validate a partial binding#
curl -X POST https://app.sonarsend.com/t/{slug}/api/templates/dog-ear-weekly/validate-binding \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_binding": {
"parameters": { "preview_text": "Pricing strategies…" },
"slots": {},
"sections": {}
}
}'
Returns structured diagnostics — valid: false does NOT return 400.
The response shape is the same whether the binding passes or fails:
{
"valid": false,
"issues": [
{
"path": "sections.secondary_articles",
"code": "required_missing",
"message": "Required section 'secondary_articles' is missing.",
"fix": "Add at least 1 row(s). Each row needs: headline, url, description, cta."
},
{
"path": "slots.pitch_block",
"code": "required_missing",
"message": "Required slot 'pitch_block' is missing from binding.",
"fix": "Set slots.pitch_block.strategy + block_id. Eligible blocks: pitch-go-deeper, pitch-peer-benchmark, pitch-testimonial."
}
]
}
Issue codes (machine-readable for branching in agent code):
| Code | Meaning |
|---|---|
unknown_param / unknown_section / unknown_slot | Binding references something the schema doesn’t declare. |
required_missing | Schema marks the field required + no default + not in binding. |
invalid_type | Value doesn’t match the param’s type or validation. |
min_rows / max_rows | Section row count outside [min, max]. |
invalid_strategy | Slot strategy isn’t in allowed_strategies. |
block_not_eligible | Slot’s block_id doesn’t match the slot’s tag_filter. |
shape | Top-level binding malformed (e.g. parameters is an array). |
3. Preview the rendered output#
Two modes:
Strict (default)#
curl -X POST https://app.sonarsend.com/t/{slug}/api/templates/dog-ear-weekly/render \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_binding": { "parameters": {…}, "slots": {…}, "sections": {…} }
}'
Errors with 400 INVALID_BINDING if validation fails. Use this once
you’ve fully constructed the binding.
{
"subject": "",
"body_html": "<!doctype html><html>…</html>",
"body_mjml": "<mjml><mj-body>…</mj-body></mjml>",
"body_text": "…",
"slots": [],
"template_snapshot": { /* schema captured at render time */ }
}
LLM-friendly (include_options: true)#
curl -X POST https://app.sonarsend.com/t/{slug}/api/templates/dog-ear-weekly/render \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_binding": { "parameters": { "preview_text": "Pricing…" } },
"include_options": true
}'
When include_options: true is set:
- The render is lenient — missing required fields get auto-filled with placeholder values so the render proceeds.
- The response includes
missing(the same diagnosticsvalidate-bindingreturns) ANDbinding_options(the discovery payload). - One round trip for the agent’s “what did I render, what’s still missing, what are my options?” loop.
{
"subject": "",
"body_html": "<!doctype html>…[preview_text]…</html>",
"body_mjml": "…",
"body_text": "…",
"missing": [/* same shape as validate-binding's issues */],
"binding_options": {/* same shape as binding-options endpoint */}
}
4. Create the broadcast#
curl -X POST https://app.sonarsend.com/t/{slug}/api/broadcasts \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "SB Newsletter — week of Apr 28",
"include_segment_keys": ["all-subscribers"],
"email_type_key": "weekly_newsletter",
"content": {
"template_slug": "dog-ear-weekly",
"template_binding": {
"parameters": { "preview_text": "Pricing strategies that actually move the needle" },
"sections": {
"secondary_articles": [
{ "headline": "Why dashboards…", "url": "https://…", "description": "…", "cta": "Read more" }
]
},
"slots": {
"pitch_block": { "strategy": "fixed", "block_id": "pitch-go-deeper", "parameters": {} }
}
}
}
}'
Returns 201 with the persisted broadcast (id, status, content with
resolved body_html / body_mjml / body_text, template_snapshot).
For API-key callers, the response uses content.template_slug (the
human-readable slug) instead of content.template_id (internal UUID).
Idempotency-Key — if your network errors mid-request and you
retry with the same key, the second call returns the original 201
response with an Idempotency-Replay: true response header. Cached
24h per tenant. Format: 8-128 chars, alphanumeric + - + _.
Errors#
400 INVALID_BINDING— the binding doesn’t match the template’s schema (missing required fields, type mismatches, undeclared section rows, etc.). Usevalidate-bindingfirst to surface these without creating a draft.400 MJML_COMPILE_FAILED— the post-binding MJML compiles to empty html. Templates pass MJML validation at create/update time (MJML_INVALID), so reaching this code typically means a slot binding pulled in a block whose MJML breaks the parent’s structure. Inspect the rendered MJML viaPOST /api/templates/{id}/renderto find the offending fragment.400 NO_CONTENT— the request omitted both inline body fields andtemplate_id/template_binding.
GET /api/broadcasts/{id} self-heals legacy template-mode rows that
were persisted with empty body_html (a result of the silent-empty
compile bug fixed in #189) by re-resolving the binding on read. The
heal is non-persistent — re-saving the broadcast (PUT /api/broadcasts/{id}) writes the resolved bodies back permanently.
5. Schedule#
curl -X POST https://app.sonarsend.com/t/{slug}/api/broadcasts/<id>/schedule \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "scheduled_at": "2026-05-01T14:00:00Z" }'
When the schema drifts#
If a template is edited after a broadcast was saved, the broadcast’s
template_snapshot falls out of sync. Use rebase-preview to resolve:
curl -X POST https://app.sonarsend.com/t/{slug}/api/broadcasts/<id>/rebase-preview \
-H "X-API-Key: $API_KEY"
Returns { merged_binding, merged_snapshot, diff }. Apply the
merged_binding by PUT /t/{slug}/api/broadcasts/<id> with the new
content.template_binding. The diff describes what changed — agents
can decide if they need to re-prompt the user (e.g. a new required
parameter was added).
Reference: the LLM agent loop#
1. opts = GET /api/templates/{slug}/binding-options
2. binding = build_initial_binding(opts)
3. while True:
result = POST /api/templates/{slug}/render
{ template_binding: binding, include_options: true }
if result.missing.length == 0: break
binding = improve_binding(binding, result.missing, opts)
4. broadcast = POST /api/broadcasts
{ name, content: { template_slug: slug, template_binding: binding }, … }
header Idempotency-Key: <uuid>
5. POST /api/broadcasts/{broadcast.id}/schedule
{ scheduled_at: "…" }
Single round trip per iteration. Per-issue code + fix on every
diagnostic = unambiguous instructions for the LLM. binding_options
on every render call = always-fresh menu of valid choices.
YAML bindings — composer only#
The SonarSend admin UI accepts YAML for binding pastes (friendlier for human authors). The external API is JSON-only. JSON is the binding format on the wire.