Messages
Message CRUD, sending (direct and bulk), recipient management, AI content generation, and lifecycle operations.
All endpoints require authentication via the X-API-Key header. See Authentication.
Message lifecycle
Messages follow this lifecycle:
draft → building → ready → scheduled or sending → completed / failed / cancelled
- draft: Initial state before recipients are built.
- building: Async recipient record creation in progress (dispatched as a cloud task after create/update).
- ready: Recipients built successfully. For
personal-sms, this is the terminal pre-send state (manual tracking only). - scheduled: A future
sendAttime is set; a GCP Cloud Task is queued. - sending / in-progress: Actively dispatching to recipients.
- completed / sent: All recipients processed.
- failed: Building timed out (>6 hours) or send failed.
- cancelled: Manually cancelled.
Supported platforms
| Platform | Direct send | Bulk create | Notes |
|---|---|---|---|
email | Yes | Yes | Requires subject. Uses SendGrid. |
stacked-sms | Yes | Yes | SMS segment count validated against org config. |
personal-sms | No | Yes | Manual status tracking only — API does not send. Large lists become paced campaigns; see below. |
web-chat | Yes | Yes | Requires contact webChatId. |
twitter | No | Yes | Deprecated. |
Create a message
POST /messages/create
Creates a message in building state. Recipient records are built asynchronously via a cloud task after the API returns.
Must provide body or attachment (or both). Email platform requires subject.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
senderId | string | No | User ID of the sender. Defaults to authenticated user. |
title | string | No | Display title for the message. |
recipients | object | Yes | contactIds (string[]), boardIds (string[]) and/or placeholderIds (string[]). At least one non-empty array required. placeholderIds means "every contact with media in this placeholder", resolved when the message is built; it must be exactly [attachment.id] of a placeholder attachment. |
body | object | Conditional | { type: "text" | "html", value: string, subject?: string }. Required if no attachment. |
platform | string | Yes | One of: email, stacked-sms, personal-sms, web-chat, twitter. |
sendAt | number | null | No | Unix timestamp (ms, must be in the future), or null for immediate. |
subject | string | Conditional | Required for email platform. |
attachment | object | Conditional | { type: "media" | "placeholder", id: string }. Required if no body. A placeholder attachment may add onMissing: "skip" (default; the contact gets an error row), "none" (sent without media; requires body) or "media" (sent with fallbackMediaId, an image of your org). |
calendarEventId | string | No | Link to a calendar event. |
replyTo | string | No | User ID for reply-to. |
linkId | string | No | Short link ID. Requires gaEvent when set. |
gaEvent | string | No | GA4 event name. Required when linkId is set. |
emailBuilderTemplate | object | No | { html: string, name: string, css?: string, emailId?: string }. |
parentMessageId | string | No | Parent message ID for follow-up campaigns. |
pacing | object | Conditional | personal-sms only. { timezone: string (IANA, required), finishBy?: number (ms), batchSize?: number, weekdaysOnly?: boolean, aiVariants?: boolean }. Required when the recipient list is over the organization's personal-sms recipient limit — see below. |
Example request
Response
Large personal-sms lists become campaigns, not errors
A personal text whose recipient list is over the organization's personal-sms recipient limit is
not created as one message and is not rejected for exceeding that limit. It is created as a
campaign: a container plus a set of batches that are paced over the days the deadline allows. The
limit is the threshold itself, so the two cannot disagree, and it is counted the same way either
way: segments in boardIds are expanded, and the count is deduplicated by phone number.
Three things differ from a normal create, and integrations should handle all three:
- The response is the campaign container, with
messageKind: "campaign"andstatus: "building", and it has no batches yet. Itsrecipientsis the caller's selection as sent, not the resolved audience. A build task then resolves the audience, keeping one recipient per phone number across the whole campaign (a contact with media in the attached placeholder claims a shared phone first), and splits it into batches that each hold exactly the resolved batch size of recipients who can receive (the last batch takes the rest); the campaign then moves tostatus: "campaign-active". Batches appear within about a minute and are read fromGET /messages/:idorGET /messages/:id/batches; they carryrecipientCount, neverrecipients.contactIds. Contacts who cannot receive (archived, no phone, opted out, or excluded by the placeholder attachment or its rules) become error rows that stay on the container, not on any batch. If nobody in the audience can receive, or the audience would need more batches than the organization allows, the campaign ends instatus: "error"with anerrorMessageinstead of becoming active. pacing.timezoneis required. Without it the request is rejected, since there is no default zone to work a deadline backwards from.- A campaign is never sent by this API. Each batch is opened on its schedule and sent by a
person from the mobile app, exactly like any other
personal-smsmessage. A caller that creates a message and then polls forstatus: "completed"will wait indefinitely.GET /messages/:idon a campaign returns itsbatchesand acampaignProgressrollup; poll that instead.
Below the threshold, pacing is accepted and ignored: the message is created normally. Sending
pacing is not a request to batch.
finishBy and batchSize are independent optional inputs, and any combination is valid:
batchSize | finishBy | Result |
|---|---|---|
| absent | absent | Size derived from org policy; every batch opens at once. |
| present | absent | Batches of that size, all opened at once. |
| absent | present | Size derived to fit the days the deadline leaves. |
| present | present | Batches of that size, spread across the deadline. |
An explicit batchSize is also an explicit request to batch, so it becomes the split threshold for
that request: 100 recipients at batchSize: 25 creates 4 batches even though 100 is under the
organization's personal-sms recipient limit. A list that fits in one batch (3 recipients at
batchSize: 100) stays a single message.
POST /messages/plan accepts the identical pacing object and resolves the plan with the same
code the create path uses, so the preview is authoritative: the batch count, size and schedule it
returns are what creation produces, and a batchSize it rejects is one creation would reject.
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Missing or invalid body/attachment, invalid platform, missing subject for email, invalid senderId, empty recipients, SMS segment count exceeds org max, invalid linkId/gaEvent combination. |
| 400 | This list is large enough to be sent in batches... | personal-sms list over that limit with no pacing.timezone. |
| 400 | Pacing is only available for personal texts | pacing sent on a platform other than personal-sms. |
| 400 | Batch size cannot exceed this organization's personal text limit of N | Explicit pacing.batchSize above the org's personal-sms recipient limit. |
| 400 | Batch size of N would create M batches; this organization allows at most K. | Explicit pacing.batchSize small enough to exceed the org's maxBatchCount. The requested size is refused, never silently changed. |
| 400 | This message has N recipients, which is over the limit... | Recipient list over the organization's recipient limit. For personal-sms a list over that limit becomes a campaign instead, so this is only reachable there when an explicit pacing.batchSize keeps the send as one message. |
| 404 | Org not found | Organization not found. |
Generate AI message content
POST /messages/ai-generate
Generate AI-improved message content using a chat-style prompt. Returns platform-aware content (e.g. SMS respects character limits, email returns HTML-compatible text).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
messages | array | Yes | Chat-style array of { role: "user" | "assistant", content: string }. |
platform | string | No | One of: email, sms, web-chat. Provides platform-specific generation constraints. |
Example request
Response
data is the refined message itself, as a plain string.
Errors
| Status | Message | Cause |
|---|---|---|
| 401 | Unauthorized | Invalid or missing authentication. |
List messages
GET /messages
List messages with filtering. Uses offset-based pagination.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
page | number | 0 | 0-indexed. |
perPage | number | 50 | Items per page. |
senderId | string | string[] | — | Filter by sender user ID(s). |
platform | string | string[] | — | Filter by platform(s). |
status | string | string[] | — | Filter by status(es). |
ids | string | string[] | — | Filter to specific message IDs. |
sendAt | number[] | — | Date range as [startTimestamp, endTimestamp]. |
createdAt | number[] | — | Date range as [startTimestamp, endTimestamp]. |
sort | object[] | — | Array of { key, direction } where direction is 1 (asc) or -1 (desc). |
Example request
Response
stats.deliveryRate is computed only for this list, not stored on the message: sent over the
recipients reached, where reached is total less error. A recipient that errored out (for
example, a contact a campaign's audience build could not reach) was never going to be sent, so it
is excluded from the denominator rather than counted against the rate.
Get a message
GET /messages/:id
Returns a single message with full lookups (author, sender, recipients, calendar event, link). Messages stuck in building status for over 6 hours are automatically marked as failed.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Message not found | No message with that ID in your org. |
Update a message
PUT /messages/:id
Update a draft or scheduled message. Only messages with status draft or scheduled can be updated.
If body, recipients, platform, or attachment changes, all existing recipients are deleted and the message returns to building status (recipient records are rebuilt asynchronously). If the message is scheduled and sendAt changes, the previous GCP Cloud Task is cancelled.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Request body
All fields are optional. Same fields as create.
| Field | Type | Notes |
|---|---|---|
title | string | null | |
senderId | string | |
recipients | object | { contactIds?: string[], boardIds?: string[], placeholderIds?: string[] } |
body | object | { type: "text" | "html", value: string, subject?: string } |
platform | string | |
sendAt | number | string | null | |
attachment | object | null | Same shape as create, including onMissing and fallbackMediaId. |
subject | string | |
calendarEventId | string | |
replyTo | string | |
linkId | string | null | |
gaEvent | string | null | |
emailBuilderTemplate | object | null |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid data or message not in draft/scheduled status. |
| 404 | Message not found | No message with that ID in your org. |
Delete a message
DELETE /messages/:id
Delete a message. Only messages with status draft, ready, or that have already sent some recipients can be deleted. All associated recipient records are deleted.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid message ID. |
| 404 | Message not found | No message with that ID in your org. |
| 500 | Message cannot be deleted | Message status does not allow deletion. |
Send a direct message
POST /messages
Send a one-to-one message immediately. Supports a more restricted set of platforms than bulk create: stacked-sms, web-chat, and email only.
Placeholders in the body (e.g. {{firstName}}) are replaced with the contact's custom field values at send time. For new conversations, opt-out text is automatically appended.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | Yes | Recipient contact ID. |
from | string | No | Sender user ID. Defaults to authenticated user. |
body | string | Conditional | Message text. Required if no attachment. |
platform | string | Yes | One of: stacked-sms, web-chat, email. |
subject | string | Conditional | Required for email platform. |
attachment | object | Conditional | { type: "media" | "placeholder", id: string }. Required if no body. |
replyTo | string | No | User ID for reply-to. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid contact/sender ID, unsupported platform, missing body/attachment, missing subject for email, contact has opted out. |
| 500 | We could not confirm this message was sent (code: dm/send-unconfirmed) | The provider gave no usable answer (timeout, connection reset, or a provider 5xx), so the message may have been sent. Do not resend. data.directMessageId identifies the conversation record, whose status updates once the provider reports. |
| 500 | Could not save the message. Nothing was sent. (code: dm/save-failed) | The message record could not be saved before sending, so nothing was sent. A retry is safe. |
Trigger bulk send
PUT /messages/:id/send
Trigger sending for a message whose recipients are already built. Dispatches send tasks for all ready recipients.
For personal-sms, the message moves to ready (manual tracking). For scheduled messages, a GCP Cloud Task is created. For immediate sends, the message is dispatched right away.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid message ID. |
| 404 | Message not found | No message with that ID in your org. |
| 500 | Send error | Message already in progress, scheduled, or completed. |
Cancel a message
POST /messages/:id/cancel
Cancel a scheduled or in-progress message. For scheduled messages, the GCP Cloud Task is deleted. All recipients with ready status are updated to cancelled.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 404 | Message not found (code: not-found) | No message with that ID. |
| 400 | Cannot cancel message (code: invalid-status) | Message not in in-progress, scheduled, or draft status. |
| 400 | No task name (code: no-task-name) | Scheduled message missing GCP task reference. |
Update message status
POST /messages/:id/status
Manually update a message's status. Only for personal-sms platform — the API does not send these messages, it only tracks their status.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
status | string | Yes | One of: in-progress, completed. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid message ID, invalid status value, or message is not personal-sms platform. |
| 404 | Message not found | No message with that ID in your org. |
List message recipients
GET /messages/:id/recipients
List recipients for a message with optional status filtering. Uses offset-based pagination.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Message ID. |
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
page | number | 0 | 0-indexed. |
perPage | number | 50 | Items per page. |
status | string | string[] | — | Filter by recipient status: cancelled, error, failed, ready, sending, sent. |
statusMessage | string | string[] | — | Filter by failure reason, as returned in each recipient's statusMessage. Repeatable (statusMessage[]=...). An empty string matches recipients that failed with no recorded reason. Applying this filter implicitly restricts results to recipients with status failed or error; combining it with a status filter that excludes both returns an empty result set. |
sort | object[] | — | Array of { key, direction }. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid message ID. |
Bulk delete recipients
DELETE /messages/:messageId/recipients/bulk
Remove recipients from a draft message. Returns the updated message with recalculated stats.
Path parameters
| Parameter | Type | Description |
|---|---|---|
messageId | string | Message ID. Must be in draft status. |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
recipientIds | string[] | Yes | Array of recipient record IDs to remove. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid message ID, invalid recipient IDs, or message not in draft status. |
| 404 | Message not found | No message with that ID in your org. |
Update recipient status
POST /messages/:messageId/recipients/:recipientId/status
Manually update a single recipient's status. Only for personal-sms platform. Updates the parent message stats accordingly.
Path parameters
| Parameter | Type | Description |
|---|---|---|
messageId | string | Message ID. |
recipientId | string | Recipient record ID. |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
status | string | Yes | Must be sent. |
Example request
Response
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Validation error | Invalid IDs, recipient not personal-sms, invalid status, or recipient already sent. |
| 404 | Recipient not found | No recipient with that ID. |
Direct vs. bulk sending
Messages supports two sending paths:
- Direct send (
POST /messages): Synchronous, one-to-one. Limited tostacked-sms,web-chat,email. Sends immediately and returns the direct message record. Placeholders are resolved and opt-out text is appended for new conversations. - Bulk send (
POST /messages/create+PUT /messages/:id/send): Async, one-to-many. Supports all five platforms. Create builds recipient records via cloud task, then send dispatches per-recipient delivery. Supports scheduling, birthday frequencies, and campaign tracking.
Response envelope
All endpoints use the standard envelope:
Error responses:
data is null for a general failure, such as a database error or an upstream timeout. When the request is rejected because of a specific field, data carries the field(s) at fault instead:
Each issue carries a path (the dotted path into the request body) and a message. A label is added when the path is not self-describing, which today means custom fields only, for example "path": "fields.68b0a1f39c2e4d0012ab77e5", "label": "Class Year". issues is an array, but treat it as holding at most one entry: validation stops at the first failure.