SonarSend API Sending Template & block import/export

Template & block import/export

Templates and content blocks round-trip as markdown. One file holds the body, the plain-text alternative, and the parameter/section/slot schemas, so a template can live in your repository, move between workspaces, or be generated by a script.

This is the file-level counterpart to template-mode, which covers filling a template in at send time.

Required scopes#

EndpointScope
GET /api/templates/{id}/exporttemplates:read
POST /api/templates/importtemplates:write
PUT /api/templates/{id}/importtemplates:write
GET /api/blocks/{id}/exporttemplates:read
POST /api/blocks/importtemplates:write
DELETE /api/templates/{id}templates:write
DELETE /api/blocks/{id}templates:write

templates:* covers blocks as well as templates — there is no separate blocks: scope.

Template endpoints accept an id or a slug in the path, so you can address a template by the stable name you gave it rather than storing a UUID.

The file format#

Front matter for metadata, ## headings for everything else, code blocks for content:

---
kind: template
name: My Template
format: mjml
---

## MJML

​```html
<mjml>…</mjml>
​```

## Plain text

​```
The text/plain alternative.
​```

## Parameters

​```json
[ … ]
​```

## Sections

​```json
[ … ]
​```

## Slots

​```json
[ … ]
​```

Required: kind: template, name, and a body section matching the format. Everything else is optional — omit any schema heading you don’t use.

  • format defaults to mjml. The only accepted values are mjml and html.
  • The body heading must match: ## MJML for format: mjml, ## HTML for format: html. Getting this wrong is the most common import failure.
  • ## Plain text and ## Plaintext are both accepted.
  • Blocks use kind: block.

Export#

curl "https://app.example.com/t/acme/api/templates/welcome-email/export" \
  -H "X-API-Key: $API_KEY" \
  -o welcome-email.md

Responds with text/markdown; charset=utf-8 — the raw file, not JSON. Blocks export the same way from /api/blocks/{id}/export.

Markdown import currently creates a new block via POST /api/blocks/import. There is no markdown replacement endpoint for an existing block; update an existing block through the regular JSON block endpoint.

Import as a new template#

curl -X POST "https://app.example.com/t/acme/api/templates/import" \
  -H "X-API-Key: $API_KEY" \
  -H 'Content-Type: text/markdown' \
  --data-binary @welcome-email.md

Returns 201 with the created template.

Two body forms are accepted, and they are equivalent:

Content-TypeBody
text/markdown or text/plainThe raw markdown
application/json{ "markdown": "---\nkind: template\n…" }

Use the JSON form when your HTTP client makes raw bodies awkward; use text/markdown when you are posting a file straight off disk. Anything else returns 400 INVALID_PAYLOAD.

Replace an existing template#

curl -X PUT "https://app.example.com/t/acme/api/templates/welcome-email/import" \
  -H "X-API-Key: $API_KEY" \
  -H 'Content-Type: text/markdown' \
  --data-binary @welcome-email.md

Replaces the body and the schemas from the file. This is a replace, not a merge: a parameter absent from the uploaded file is removed from the template.

Check what depends on a template before replacing it:

curl "https://app.example.com/t/acme/api/templates/welcome-email/usage" \
  -H "X-API-Key: $API_KEY"

Broadcasts that already rendered from a template are unaffected — their content is frozen at send time. The risk is to drafts and sequences still pointing at it.

Keeping templates in version control#

The round-trip is stable, which makes a plain git-based workflow practical:

# Pull every template into a directory
for slug in welcome-email monthly-digest; do
  curl -s "$BASE/api/templates/$slug/export" -H "X-API-Key: $API_KEY" \
    -o "templates/$slug.md"
done

# Push a change back
curl -X PUT "$BASE/api/templates/welcome-email/import" \
  -H "X-API-Key: $API_KEY" \
  -H 'Content-Type: text/markdown' \
  --data-binary @templates/welcome-email.md

Exporting the same template twice produces identical bytes, and bodies are stored verbatim — nothing is reformatted or minified on import. So a scheduled export is a reliable drift check: a diff means someone edited the template in the app.

One wrinkle on the first export after an import: if your file has no slug in its front matter, one is generated from name (and de-duplicated if it collides), so the export comes back carrying a slug your source did not have. Put an explicit slug in front matter and the file round-trips unchanged.

Deleting a template or block#

DELETE /api/templates/{id_or_slug}
DELETE /api/blocks/{id_or_slug}

Both need templates:write and return 204 on success.

A delete is refused while the template or block is still in use — 409 IN_USE, with the usage counts in details:

{
  "error": {
    "code": "IN_USE",
    "message": "Template is referenced by active drafts or recent broadcasts",
    "details": { "active_drafts": 2, "recent_broadcasts": 1 }
  }
}

For a template, “in use” means active drafts or recent broadcasts; for a block, active drafts only. Pass ?force=true to skip the check and delete anyway.

Think about force before you reach for it. It removes the definition that in-flight drafts still point at, and unlike an export/import round-trip there is nothing to restore from unless you kept the markdown. Exporting first (above) gives you that copy.

Errors#

CodeStatusMeaning
INVALID_PAYLOAD400Body was neither raw markdown nor { "markdown": … }.
IN_USE409Delete refused: still referenced. details carries the counts.
NOT_FOUND404No template or block with that id or slug.
INSUFFICIENT_SCOPE403The key is missing templates:write.

Malformed markdown — a missing required heading, unparseable JSON in a schema block — is reported as a validation error naming the section at fault.

See errors.md for the full envelope.