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

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 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://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):

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://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 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://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.). 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 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://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.