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 sendAt time 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

PlatformDirect sendBulk createNotes
emailYesYesRequires subject. Uses SendGrid.
stacked-smsYesYesSMS segment count validated against org config.
personal-smsNoYesManual status tracking only — API does not send. Large lists become paced campaigns; see below.
web-chatYesYesRequires contact webChatId.
twitterNoYesDeprecated.

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

FieldTypeRequiredNotes
senderIdstringNoUser ID of the sender. Defaults to authenticated user.
titlestringNoDisplay title for the message.
recipientsobjectYescontactIds (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.
bodyobjectConditional{ type: "text" | "html", value: string, subject?: string }. Required if no attachment.
platformstringYesOne of: email, stacked-sms, personal-sms, web-chat, twitter.
sendAtnumber | nullNoUnix timestamp (ms, must be in the future), or null for immediate.
subjectstringConditionalRequired for email platform.
attachmentobjectConditional{ 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).
calendarEventIdstringNoLink to a calendar event.
replyTostringNoUser ID for reply-to.
linkIdstringNoShort link ID. Requires gaEvent when set.
gaEventstringNoGA4 event name. Required when linkId is set.
emailBuilderTemplateobjectNo{ html: string, name: string, css?: string, emailId?: string }.
parentMessageIdstringNoParent message ID for follow-up campaigns.
pacingobjectConditionalpersonal-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

curl -X POST https://<host>/api/messages/create \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Spring Welcome",
    "recipients": { "contactIds": ["664a...", "664b..."] },
    "body": { "type": "text", "value": "Welcome to spring training!" },
    "platform": "stacked-sms"
  }'

Response

{
  "error": false,
  "success": true,
  "message": "Message created successfuly",
  "code": 200,
  "data": {
    "_id": "668a...",
    "orgId": "...",
    "title": "Spring Welcome",
    "status": "building",
    "authorId": "...",
    "senderId": "...",
    "recipients": { "contactIds": ["664a...", "664b..."] },
    "body": { "type": "text", "value": "Welcome to spring training!" },
    "platform": "stacked-sms",
    "createdAt": 1716000000000,
    "updatedAt": 1716000000000,
    "archived": false
  }
}

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" and status: "building", and it has no batches yet. Its recipients is 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 to status: "campaign-active". Batches appear within about a minute and are read from GET /messages/:id or GET /messages/:id/batches; they carry recipientCount, never recipients.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 in status: "error" with an errorMessage instead of becoming active.
  • pacing.timezone is 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-sms message. A caller that creates a message and then polls for status: "completed" will wait indefinitely. GET /messages/:id on a campaign returns its batches and a campaignProgress rollup; 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:

batchSizefinishByResult
absentabsentSize derived from org policy; every batch opens at once.
presentabsentBatches of that size, all opened at once.
absentpresentSize derived to fit the days the deadline leaves.
presentpresentBatches 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.

{
  "platform": "personal-sms",
  "recipients": { "boardIds": ["665c..."] },
  "body": { "type": "text", "value": "Thank you for your support this year." },
  "pacing": { "timezone": "America/Chicago", "finishBy": 1719000000000, "weekdaysOnly": true }
}

Errors

StatusMessageCause
400Validation errorMissing or invalid body/attachment, invalid platform, missing subject for email, invalid senderId, empty recipients, SMS segment count exceeds org max, invalid linkId/gaEvent combination.
400This list is large enough to be sent in batches...personal-sms list over that limit with no pacing.timezone.
400Pacing is only available for personal textspacing sent on a platform other than personal-sms.
400Batch size cannot exceed this organization's personal text limit of NExplicit pacing.batchSize above the org's personal-sms recipient limit.
400Batch 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.
400This 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.
404Org not foundOrganization 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

FieldTypeRequiredNotes
messagesarrayYesChat-style array of { role: "user" | "assistant", content: string }.
platformstringNoOne of: email, sms, web-chat. Provides platform-specific generation constraints.

Example request

curl -X POST https://<host>/api/messages/ai-generate \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      { "role": "user", "content": "Write a welcome message for new recruits" }
    ],
    "platform": "sms"
  }'

Response

{
  "success": true,
  "message": "got message recommendation",
  "data": "Welcome to the team! We're excited to have you on board. Check your email for next steps."
}

data is the refined message itself, as a plain string.

Errors

StatusMessageCause
401UnauthorizedInvalid or missing authentication.

List messages

GET /messages

List messages with filtering. Uses offset-based pagination.

Query parameters

ParameterTypeDefaultNotes
pagenumber00-indexed.
perPagenumber50Items per page.
senderIdstring | string[]—Filter by sender user ID(s).
platformstring | string[]—Filter by platform(s).
statusstring | string[]—Filter by status(es).
idsstring | string[]—Filter to specific message IDs.
sendAtnumber[]—Date range as [startTimestamp, endTimestamp].
createdAtnumber[]—Date range as [startTimestamp, endTimestamp].
sortobject[]—Array of { key, direction } where direction is 1 (asc) or -1 (desc).

Example request

curl "https://<host>/api/messages?page=0&perPage=25&platform=email&status=completed" \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": [
    {
      "_id": "668a...",
      "title": "Spring Welcome",
      "status": "completed",
      "author": { "_id": "...", "firstName": "John", "lastName": "Doe" },
      "sender": { "_id": "...", "firstName": "John", "lastName": "Doe" },
      "recipients": { "contactIds": ["664a..."] },
      "body": { "type": "text", "value": "Welcome!" },
      "platform": "email",
      "createdAt": 1716000000000,
      "stats": {
        "ready": 0,
        "pending": 0,
        "sent": 50,
        "failed": 2,
        "error": 0,
        "cancelled": 0,
        "total": 52,
        "deliveryRate": 0.96
      }
    }
  ],
  "pagination": {
    "page": 0,
    "perPage": 25,
    "total": 100
  }
}

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

ParameterTypeDescription
idstringMessage ID.

Example request

curl https://<host>/api/messages/668a... \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": {
    "_id": "668a...",
    "title": "Spring Welcome",
    "status": "completed",
    "author": { "_id": "...", "firstName": "John", "lastName": "Doe" },
    "sender": { "_id": "...", "firstName": "John", "lastName": "Doe" },
    "body": { "type": "text", "value": "Welcome!" },
    "platform": "stacked-sms",
    "stats": { "ready": 0, "sent": 50, "failed": 0, "total": 50 },
    "createdAt": 1716000000000,
    "updatedAt": 1716000000000
  }
}

Errors

StatusMessageCause
404Message not foundNo 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

ParameterTypeDescription
idstringMessage ID.

Request body

All fields are optional. Same fields as create.

FieldTypeNotes
titlestring | null
senderIdstring
recipientsobject{ contactIds?: string[], boardIds?: string[], placeholderIds?: string[] }
bodyobject{ type: "text" | "html", value: string, subject?: string }
platformstring
sendAtnumber | string | null
attachmentobject | nullSame shape as create, including onMissing and fallbackMediaId.
subjectstring
calendarEventIdstring
replyTostring
linkIdstring | null
gaEventstring | null
emailBuilderTemplateobject | null

Example request

curl -X PUT https://<host>/api/messages/668a... \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Updated Spring Welcome", "sendAt": 1716100000000 }'

Response

{
  "error": false,
  "success": true,
  "message": "Message updated successfully",
  "code": 200,
  "data": {
    "_id": "668a...",
    "title": "Updated Spring Welcome",
    "status": "draft",
    "sendAt": 1716100000000
  }
}

Errors

StatusMessageCause
400Validation errorInvalid data or message not in draft/scheduled status.
404Message not foundNo 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

ParameterTypeDescription
idstringMessage ID.

Example request

curl -X DELETE https://<host>/api/messages/668a... \
  -H "X-API-Key: your-api-key"

Response

{
  "error": false,
  "success": true,
  "message": "Message deleted successfully",
  "code": 200,
  "data": null
}

Errors

StatusMessageCause
400Validation errorInvalid message ID.
404Message not foundNo message with that ID in your org.
500Message cannot be deletedMessage 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

FieldTypeRequiredNotes
tostringYesRecipient contact ID.
fromstringNoSender user ID. Defaults to authenticated user.
bodystringConditionalMessage text. Required if no attachment.
platformstringYesOne of: stacked-sms, web-chat, email.
subjectstringConditionalRequired for email platform.
attachmentobjectConditional{ type: "media" | "placeholder", id: string }. Required if no body.
replyTostringNoUser ID for reply-to.

Example request

curl -X POST https://<host>/api/messages \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "664a...",
    "body": "Hey {{firstName}}, checking in!",
    "platform": "stacked-sms"
  }'

Response

{
  "success": true,
  "message": "Message sent successfully",
  "data": {
    "directMessage": {
      "_id": "669a...",
      "orgId": "...",
      "type": "direct",
      "userId": "...",
      "contactId": "664a...",
      "messageData": {
        "body": "Hey Jane, checking in!",
        "type": "text"
      },
      "timestamp": 1716000000000,
      "direction": "out",
      "platform": "stacked-sms",
      "read": false
    }
  }
}

Errors

StatusMessageCause
400Validation errorInvalid contact/sender ID, unsupported platform, missing body/attachment, missing subject for email, contact has opted out.
500We 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.
500Could 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

ParameterTypeDescription
idstringMessage ID.

Example request

curl -X PUT https://<host>/api/messages/668a.../send \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "message": "Message sent successfully",
  "data": {
    "_id": "668a...",
    "status": "dispatched"
  }
}

Errors

StatusMessageCause
400Validation errorInvalid message ID.
404Message not foundNo message with that ID in your org.
500Send errorMessage 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

ParameterTypeDescription
idstringMessage ID.

Example request

curl -X POST https://<host>/api/messages/668a.../cancel \
  -H "X-API-Key: your-api-key"

Response

{
  "error": false,
  "success": true,
  "message": "Message cancelled successfully",
  "code": 200,
  "data": null
}

Errors

StatusMessageCause
404Message not found (code: not-found)No message with that ID.
400Cannot cancel message (code: invalid-status)Message not in in-progress, scheduled, or draft status.
400No 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

ParameterTypeDescription
idstringMessage ID.

Request body

FieldTypeRequiredNotes
statusstringYesOne of: in-progress, completed.

Example request

curl -X POST https://<host>/api/messages/668a.../status \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "status": "completed" }'

Response

{
  "success": true,
  "message": "Message status updated successfully",
  "data": {
    "_id": "668a...",
    "status": "completed",
    "platform": "personal-sms"
  }
}

Errors

StatusMessageCause
400Validation errorInvalid message ID, invalid status value, or message is not personal-sms platform.
404Message not foundNo 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

ParameterTypeDescription
idstringMessage ID.

Query parameters

ParameterTypeDefaultNotes
pagenumber00-indexed.
perPagenumber50Items per page.
statusstring | string[]—Filter by recipient status: cancelled, error, failed, ready, sending, sent.
statusMessagestring | 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.
sortobject[]—Array of { key, direction }.

Example request

curl "https://<host>/api/messages/668a.../recipients?status=sent&perPage=25" \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": [
    {
      "_id": "669b...",
      "messageId": "668a...",
      "status": "sent",
      "body": { "type": "text", "value": "Welcome, Jane!" },
      "senderId": "...",
      "recipientId": "664a...",
      "recipient": { "_id": "664a...", "firstName": "Jane", "lastName": "Doe" },
      "platform": "stacked-sms",
      "sentAt": 1716000100000,
      "clickCount": 0,
      "openCount": 0
    }
  ],
  "pagination": {
    "page": 0,
    "perPage": 25,
    "total": 50
  }
}

Errors

StatusMessageCause
400Validation errorInvalid 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

ParameterTypeDescription
messageIdstringMessage ID. Must be in draft status.

Request body

FieldTypeRequiredNotes
recipientIdsstring[]YesArray of recipient record IDs to remove.

Example request

curl -X DELETE https://<host>/api/messages/668a.../recipients/bulk \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "recipientIds": ["669b...", "669c..."] }'

Response

{
  "success": true,
  "message": "Success",
  "data": {
    "_id": "668a...",
    "status": "draft",
    "stats": { "ready": 8, "total": 8 }
  }
}

Errors

StatusMessageCause
400Validation errorInvalid message ID, invalid recipient IDs, or message not in draft status.
404Message not foundNo 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

ParameterTypeDescription
messageIdstringMessage ID.
recipientIdstringRecipient record ID.

Request body

FieldTypeRequiredNotes
statusstringYesMust be sent.

Example request

curl -X POST https://<host>/api/messages/668a.../recipients/669b.../status \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "status": "sent" }'

Response

{
  "success": true,
  "message": "Message recipient status updated successfully",
  "data": {
    "_id": "669b...",
    "status": "sent",
    "platform": "personal-sms"
  }
}

Errors

StatusMessageCause
400Validation errorInvalid IDs, recipient not personal-sms, invalid status, or recipient already sent.
404Recipient not foundNo recipient with that ID.

Direct vs. bulk sending

Messages supports two sending paths:

  • Direct send (POST /messages): Synchronous, one-to-one. Limited to stacked-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": false,
  "success": true,
  "message": "...",
  "code": 200,
  "data": "...",
  "pagination": {}
}

Error responses:

{
  "error": true,
  "success": false,
  "message": "...",
  "code": 400,
  "data": null
}

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:

{
  "error": true,
  "success": false,
  "message": "phone: Phone parse failed: The string supplied did not seem to be a phone number (input: <masked>)",
  "code": 400,
  "data": {
    "issues": [
      {
        "path": "phone",
        "message": "Phone parse failed: The string supplied did not seem to be a phone number (input: <masked>)"
      }
    ]
  }
}

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.