SonarSend API Sending Email library

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#

EndpointScope
GET /api/messagesmessages:read
GET /api/messages/{id}messages:read
GET /api/messages/{id}/versionsmessages:read
GET /api/messages/{id}/versions/{version}messages:read
POST /api/messages/{id}/rebase-previewmessages:read
POST /api/messagesmessages:write
PATCH /api/messages/{id}messages:write
POST /api/messages/{id}/archivemessages:write
POST /api/messages/{id}/duplicatemessages:write
DELETE /api/messages/{id}messages:write

messages:write implies messages:read.

Test sends are not available to API keys.

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
ParameterNotes
statusdraft, published, or archived
searchSubstring match on name
limit1–200, default 50
offsetDefault 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#

CodeStatusMeaning
NOT_FOUND404No entry, or no such version.
NO_CONTENT400The entry has no saved version yet.
NOT_TEMPLATE_MODE400Rebase asked for on an entry not built from a template.
INVALID_CONTENT400content isn’t a valid resolved-content object.
INVALID_INPUT400Metadata failed validation.
INVALID_SLUG400No usable slug could be derived from slug/name.
SLUG_TAKEN409Could not derive a unique slug (100 collisions).
IN_USE409Delete refused — something still references it.
UNAVAILABLE503Delete gate not configured in this environment.
API_KEY_FORBIDDEN403Route not open to API keys — e.g. send-test.
INSUFFICIENT_SCOPE403The key is missing messages:read/messages:write.

See errors.md for the full envelope.