Program Messages
A program can carry a message plan: the emails and texts everyone on it receives, timed against each cohort's own dates. The plan lives on the program; every cohort inherits it and schedules from its own sessions, so "a week before we start" is written once and resolves differently for each intake.
Two resources:
- Messages โ the plan itself, attached to a program.
- Message runs โ one scheduled send for one cohort, produced from the plan.
Sends are resolved to recipients at send time from the cohort's live enrollments, so a late joiner is included and a cancellation is not.
Authenticationโ
Reads require series:read; writes require series:write. Programs are a Growth-plan feature โ a plan without it gets 403 plan_feature_required.
List messagesโ
GET /v1/series/{id}/messages
Returns the plan for the program, including paused messages. Calling it with a cohort id returns the plan the cohort inherits.
Exampleโ
curl "https://app.equipdash.com/api/v1/series/11/messages" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"id": 42,
"program_id": 11,
"position": 0,
"name": "Reminder โ you start next week",
"channel": "email",
"audience": "participants",
"timing": {
"anchor": "program_start",
"anchor_session_number": null,
"offset_value": 7,
"offset_unit": "days",
"offset_direction": "before",
"send_at_local_time": "09:00",
"fixed_at": null,
"summary": "7 days before the program starts"
},
"subject": "{{program_name}} starts {{start_date}}",
"email_blocks": [
{ "id": "b1", "type": "heading", "data": { "text": "Next week, {{first_name}}" } }
],
"sms_body": null,
"enabled": true,
"before_confirmation": false,
"created_at": "2026-08-08T10:00:00Z",
"updated_at": "2026-08-08T10:00:00Z"
}
],
"meta": { "request_id": "req_abc123", "timestamp": "2026-08-08T10:05:00Z" }
}
Create a messageโ
POST /v1/series/{id}/messages
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Internal label โ the customer never sees it |
channel | string | Yes | email or sms |
audience | string | No | participants (default), booking_contact, or both |
anchor | string | No | What the timing is measured against โ see below |
anchor_session_number | integer | No | 1-based session position, for session_number |
offset_value | integer | No | 0 means "straight away" |
offset_unit | string | No | minutes, hours, days (default), weeks |
offset_direction | string | No | before or after |
send_at_local_time | string | No | HH:MM in the supplier's timezone; used for day/week offsets |
fixed_at | datetime | No | Only for the fixed_date anchor |
subject | string | No | Email only |
email_blocks | array | No | The visual editor's block list |
sms_body | string | No | Text only, max 640 characters |
enabled | boolean | No | Defaults to on |
Anchorsโ
| Anchor | Sends |
|---|---|
enrollment | Once per booking, timed from when it was made (after only) |
program_start | Once per cohort, from its first session |
program_end | Once per cohort, from the end of its last session |
each_session | Once per session in the cohort |
session_number | Once, from the session at anchor_session_number |
fixed_date | Once, at fixed_at (after only) |
Day- and week-grained offsets land at send_at_local_time in the supplier's timezone, so a cohort spanning a daylight-saving change still sends at the same local hour. Minute- and hour-grained offsets keep the session's own clock time.
Merge tagsโ
Usable in subject, sms_body and text blocks. A tag that does not exist is refused with 422 (field: tags), naming the tag and the one that was probably meant, and nothing is saved.
{{first_name}} {{last_name}} {{program_name}} {{cohort_name}} {{start_date}} {{start_time}} {{end_date}} {{session_date}} {{session_time}} {{session_number}} {{session_count}} {{location}} {{booking_reference}} {{balance_due}} {{supplier_name}}
Exampleโ
curl -X POST "https://app.equipdash.com/api/v1/series/11/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Kit list",
"channel": "sms",
"audience": "participants",
"anchor": "each_session",
"offset_value": 1,
"offset_unit": "days",
"offset_direction": "before",
"sms_body": "Hi {{first_name}}, {{program_name}} is on tomorrow at {{session_time}}, {{location}}."
}'
Returns 201 Created with the message.
Update a messageโ
PATCH /v1/series/{id}/messages/{messageId}
Accepts any subset of the create fields. Changing the timing reschedules every future send across all cohorts; anything already sent is untouched.
Remove a messageโ
DELETE /v1/series/{id}/messages/{messageId}
Returns 204 No Content. Future cohorts stop sending it; already-sent messages stay in each customer's history.
List a cohort's scheduleโ
GET /v1/series/{id}/message-runs
Optional ?status= filter (scheduled, sent, skipped, failed, cancelled).
{
"data": [
{
"id": 900,
"cohort_id": 12,
"program_message_id": 42,
"message_name": "Reminder โ you start next week",
"channel": "email",
"booking_id": null,
"session_id": null,
"scheduled_at": "2026-09-08T09:00:00Z",
"status": "scheduled",
"status_label": "Scheduled",
"sent_at": null,
"recipients_count": 0,
"failure_reason": null
}
],
"meta": { "request_id": "req_abc123", "timestamp": "2026-08-08T10:05:00Z" }
}
Send one nowโ
POST /v1/series/{id}/message-runs/{runId}/send
Sends immediately, ignoring the schedule but not the plan. Returns the run plus recipients_count. A run that has already sent, been skipped or been cancelled returns 409 already_resolved โ a run leaves scheduled exactly once, so this is safe to retry.
Skip oneโ
POST /v1/series/{id}/message-runs/{runId}/skip
Skips it for this cohort only. Every other cohort is unaffected.
Errorsโ
| Status | Code | Description |
|---|---|---|
| 401 | authentication_required | No API key provided |
| 403 | insufficient_scope | Key lacks series:read / series:write |
| 403 | plan_feature_required | The plan does not include Programs |
| 404 | resource_not_found | The program, message or scheduled send does not exist for this business |
| 409 | already_resolved | The send is not waiting to go out |
| 422 | validation_error | See error.details for the offending fields |