Email library
Named, reusable emails with version history. A library entry holds resolved content — subject, HTML, text — and every edit appends a new immutable version rather than overwriting the last one.
This is a different thing from a template. A template is structure, filled in at send time. A library entry is finished content, already resolved, ready to be pointed at by a sequence or copied into a broadcast.
Required scopes#
| Endpoint | Scope |
|---|---|
GET /api/messages | messages:read |
GET /api/messages/{id} | messages:read |
GET /api/messages/{id}/versions | messages:read |
GET /api/messages/{id}/versions/{version} | messages:read |
POST /api/messages/{id}/rebase-preview | messages:read |
POST /api/messages | messages:write |
PATCH /api/messages/{id} | messages:write |
POST /api/messages/{id}/archive | messages:write |
POST /api/messages/{id}/duplicate | messages:write |
DELETE /api/messages/{id} | messages:write |
messages:write implies messages:read.
POST /api/messages/{id}/send-test
exists but is session-only and deliberately so: every other endpoint here
edits stored content, while that one puts mail in real inboxes. Keep the
blast radius of a leaked key at “our drafts were edited”.
templates:* does not grant access here, and vice versa — they are separate
resources with separate version histories.
Versions#
Versions are append-only and immutable. PATCH with content does not
modify version n; it writes version n+1 and moves current_version.
That matters for anything referencing an entry:
- A sequence node pinned to a version keeps sending that version forever, regardless of later edits.
- An unpinned reference follows
current_version, so an edit changes what goes out next. - A broadcast that has already sent is unaffected either way — its content was frozen at send time.
So editing is safe by default, and publishing a change is a deliberate act.
List#
GET /t/{tenant_slug}/api/messages?status=published&search=welcome&limit=50&offset=0
X-API-Key: sonar_xxxxx
| Parameter | Notes |
|---|---|
status | draft, published, or archived |
search | Substring match on name |
limit | 1–200, default 50 |
offset | Default 0 |
Offset-based, and it returns a total — this is a small, browsable collection,
not a growing log. Contrast the cursor-paginated
endpoints, which are the ones that page by resume point because they are
append-heavy.
Create#
curl -X POST "https://app.example.com/t/acme/api/messages" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Welcome email",
"slug": "welcome-email",
"tags": ["onboarding"],
"content": { "subject": "Welcome!", "body_html": "<p>Hi {{first_name}}</p>" }
}'
Returns 201. Only name is required. Supplying content creates version 1 at
the same time; omitting it creates an entry with no versions yet
(current_version: 0), which you can fill in later with PATCH.
slug is generated from name when omitted, and is unique per workspace — use
it to address the entry from your own code without storing a UUID.
Read#
GET /api/messages/{id} # the entry, with current_version
GET /api/messages/{id}/versions # every version, newest first
GET /api/messages/{id}/versions/{version} # one version's frozen content
The versions list carries a usage field per version when usage resolution is
configured — which sequences pin that version. Use it before editing or
deleting.
Update#
curl -X PATCH "https://app.example.com/t/acme/api/messages/{id}" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"Welcome email v2","content":{"subject":"Welcome aboard!","body_html":"…"}}'
Metadata (name, slug, tags, status) updates in place. content appends
a new version. You can send either, or both in one call.
Archive, duplicate, delete#
POST /api/messages/{id}/archive # soft — hides it, keeps history
POST /api/messages/{id}/duplicate # copies entry + versions to a new entry
DELETE /api/messages/{id} # hard delete, gated on usage
DELETE is refused while anything still references the entry — including a
sequence send node that points at it without pinning a version. Archive is the
right move for “we’re done with this but it shipped”; delete is for mistakes.
If the API returns 503 UNAVAILABLE on delete, the usage resolver isn’t wired
up in that environment and the server is declining to delete rather than
risking an unchecked removal.
Rebase preview#
curl -X POST "https://app.example.com/t/acme/api/messages/{id}/rebase-preview" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"version": 3}'
For entries built from a template: shows what the content would become if
re-resolved against the template’s current version — what a template change
would do to this email. Computes and returns; writes nothing, which is why
it needs only messages:read despite being a POST.
Returns NO_CONTENT if the entry has no saved version yet, NOT_FOUND if that
version number doesn’t exist, and NOT_TEMPLATE_MODE if the entry wasn’t built
from a template and so has nothing to rebase onto.
Errors#
| Code | Status | Meaning |
|---|---|---|
NOT_FOUND | 404 | No entry, or no such version. |
NO_CONTENT | 400 | The entry has no saved version yet. |
NOT_TEMPLATE_MODE | 400 | Rebase asked for on an entry not built from a template. |
INVALID_CONTENT | 400 | content isn’t a valid resolved-content object. |
INVALID_INPUT | 400 | Metadata failed validation. |
INVALID_SLUG | 400 | No usable slug could be derived from slug/name. |
SLUG_TAKEN | 409 | Could not derive a unique slug (100 collisions). |
IN_USE | 409 | Delete refused — something still references it. |
UNAVAILABLE | 503 | Delete gate not configured in this environment. |
API_KEY_FORBIDDEN | 403 | Route not open to API keys — e.g. send-test. |
INSUFFICIENT_SCOPE | 403 | The key is missing messages:read/messages:write. |
See errors.md for the full envelope.