Docs
API reference Sending Template-Mode API — for external clients & LLM agents

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 render
  • broadcasts:write — create / update / clone broadcasts
  • broadcasts: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):

CodeMeaning
unknown_param / unknown_section / unknown_slotBinding references something the schema doesn’t declare.
required_missingSchema marks the field required + no default + not in binding.
invalid_typeValue doesn’t match the param’s type or validation.
min_rows / max_rowsSection row count outside [min, max].
invalid_strategySlot strategy isn’t in allowed_strategies.
block_not_eligibleSlot’s block_id doesn’t match the slot’s tag_filter.
shapeTop-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 diagnostics validate-binding returns) AND binding_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.). Use validate-binding first 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 via POST /api/templates/{id}/render to 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 and template_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.