Email library
Reusable emails with immutable version history.
The Message object 10 fields
The frozen content of current_version (subject, bodies, optional template provenance), or null when current_version is 0. Present on the single-entry GET.
The latest appended version number. 0 before any content is written.
Canonical entry ID.
Stable per-tenant key, derived from name when omitted. Address the entry by it instead of the UUID.
Lifecycle. archived hides the entry while keeping its history.
draft, published, archivedList library entries
A browsable, OFFSET-paged list of library entries — so it carries a total, and has_more is derived from it. Filter by status and substring-match name with search. Each row is the entry metadata (no version content).
A page of Message objects, under data.
A page of library entries.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/messages" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/messages', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"data": [
{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft"
}
],
"next_cursor": "eyJpZCI6IjljMjE4OTVkIn0",
"has_more": true,
"total": 0
}Create a library entry
Creates a library entry. 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) to fill in later with PATCH. slug is derived from name when omitted and is unique per workspace. content accepts either raw content (subject + body_html/body_mjml) or template-mode (template_id + template_binding).
draft, published, archivedThe Message object.
The library entry was created.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/messages" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft",
"content": {}
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/messages', {
method: 'POST',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft",
"content": {}
}),
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft"
}Get a library entry
The entry plus current_content — the frozen content of its current version, or null when the entry has no version yet.
The Message object.
The library entry, with its current version's content.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft"
}Update a library entry
Updates metadata and/or content. Metadata (name, slug, tags, status) updates in place. Sending content APPENDS a new immutable version and moves current_version — it never modifies the existing version. You can send metadata, content, or both in one call; the two are applied atomically, so a bad version insert cannot leave metadata changed behind a 400.
draft, published, archivedThe Message object.
The updated entry.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X PATCH "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft",
"content": {}
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'PATCH',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft",
"content": {}
}),
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft"
}Delete a library entry
Hard-deletes the entry and its versions. Refused with 409 IN_USE while ANYTHING still references it — including a sequence send node that points at it without pinning a version. Archive instead if it shipped. Returns 503 UNAVAILABLE when the usage resolver is not wired up in this environment (the server declines rather than risk an unchecked removal). Returns 204 on success.
The entry was deleted.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X DELETE "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80', {
method: 'DELETE',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();List an entry's versions
Every version of an entry, newest first. Versions are append-only and immutable. When usage resolution is configured, each version carries a usage array naming which sequences pin it. Not paginated.
The frozen resolved content (subject, bodies, optional template provenance). Shape varies — modelled permissively.
Present on the versions list when usage resolution is configured: which sequences/broadcasts committed this version ({ version, kind, name, status }).
Version number, 1-based and append-only.
Opaque. Pass back as cursor for the next page. Null when there is no continuation — which for most endpoints means the last page, and always means a collection that is not paginated. The account-wide event feed is the exception: it returns a resumable cursor even on its final page (see that endpoint).
The entry's versions, newest first.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/versions" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/versions', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"data": [
{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"message_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"version": 0,
"content": {},
"usage": [
{}
],
"created_at": "2026-01-15T09:30:00.000Z"
}
],
"next_cursor": "eyJpZCI6IjljMjE4OTVkIn0",
"has_more": true
}Get one version
One immutable frozen version by its number. version must be a positive integer; 404 NOT_FOUND if that number does not exist.
The frozen resolved content (subject, bodies, optional template provenance). Shape varies — modelled permissively.
Present on the versions list when usage resolution is configured: which sequences/broadcasts committed this version ({ version, kind, name, status }).
Version number, 1-based and append-only.
The frozen version.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X GET "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/versions/{version}" \
-H "X-API-Key: $SONARSEND_API_KEY"const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/versions/{version}', {
method: 'GET',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"message_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"version": 0,
"content": {},
"usage": [
{}
],
"created_at": "2026-01-15T09:30:00.000Z"
}Archive a library entry
Soft-hides the entry (status → archived) while keeping its full version history. The right move for "we're done with this but it shipped"; use delete for mistakes.
The Message object.
The archived entry.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/archive" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/archive', {
method: 'POST',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft"
}Duplicate a library entry
Deep-copies the entry and all its versions into a fresh draft named "X (copy)". Returns 201 with the new entry.
The Message object.
The new copy.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/duplicate" \ -H "X-API-Key: $SONARSEND_API_KEY"
const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/duplicate', {
method: 'POST',
headers: { 'X-API-Key': process.env.SONARSEND_API_KEY },
});
const data = await res.json();{
"id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"tenant_id": "9c21895d-57f0-4a15-a1df-4dc6835d4f80",
"name": "Acme onboarding",
"slug": "acme",
"tags": [
"string"
],
"status": "draft"
}Preview a template rebase
For entries built from a template: recomputes a version's stored template binding against the template's CURRENT schema and returns { merged_binding, merged_snapshot, diff } — what a template change would do to this email. Computes and returns; writes nothing (hence messages:read despite being a POST). Defaults to current_version when version is omitted. Returns 400 NO_CONTENT if the entry has no saved version, 404 NOT_FOUND if that version does not exist, and 400 NOT_TEMPLATE_MODE if the entry was not built from a template.
The rebase preview: merged_binding, merged_snapshot, diff. Modelled permissively.
The request failed validation, or a referenced record was rejected.
The tenant slug, or the record the path names, does not exist.
curl -X POST "https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/rebase-preview" \
-H "X-API-Key: $SONARSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": 0
}'const res = await fetch('https://api.sonarsend.com/t/acme/api/messages/9c21895d-57f0-4a15-a1df-4dc6835d4f80/rebase-preview', {
method: 'POST',
headers: {
'X-API-Key': process.env.SONARSEND_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"version": 0
}),
});
const data = await res.json();{}