Template-Mode API — for external clients & LLM agents
RadarSend 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.radarsend.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: radar_…
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://api.radarsend.com/t/fletcher-co/api/templates/dog-ear-weekly/binding-options \
-H "X-API-Key: $API_KEY"
curl https://api.radarsend.com/t/fletcher-co/api/templates/187d6bd1-…-737bf1/binding-options \
-H "X-API-Key: $API_KEY"
1. Discover the binding shape#
curl https://api.radarsend.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://api.radarsend.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://api.radarsend.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://api.radarsend.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://api.radarsend.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 MJML_UNKNOWN_ELEMENT— the template’s MJML, or a block’s that a slot binding pulled in, uses an element the MJML compiler doesn’t recognize (for example MJML 3’s<mj-container>). The compiler would drop it and everything inside it. The message names the element and its line. Templates and blocks saved after this check can’t contain one, so this points at one saved before it: fix the template or block and save it.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://api.radarsend.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://api.radarsend.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 RadarSend 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.