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#
| Endpoint | Scope |
|---|---|
GET /api/templates/{id}/export | templates:read |
POST /api/templates/import | templates:write |
PUT /api/templates/{id}/import | templates:write |
GET /api/blocks/{id}/export | templates:read |
POST /api/blocks/import | templates: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.
formatdefaults tomjml. The only accepted values aremjmlandhtml.- The body heading must match:
## MJMLforformat: mjml,## HTMLforformat: html. Getting this wrong is the most common import failure. ## Plain textand## Plaintextare 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-Type | Body |
|---|---|
text/markdown or text/plain | The 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#
| Code | Status | Meaning |
|---|---|---|
INVALID_PAYLOAD | 400 | Body was neither raw markdown nor { "markdown": … }. |
IN_USE | 409 | Delete refused: still referenced. details carries the counts. |
NOT_FOUND | 404 | No template or block with that id or slug. |
INSUFFICIENT_SCOPE | 403 | The 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.