Sync attendee, staff, and contractor phone numbers into whoot. recipient lists from your own systems, including rosters pushed ahead of the dates they apply to.
An SMS recipient list is a named set of phone numbers an emergency alert or campaign can text. This API lets the system that already knows who is on site — attendee management, ticketing, a staff rota, a contractor register — own those lists directly, so nobody re-keys a roster into the dashboard on the morning of an event.
It is part of the same tenant API as user and room provisioning: the same base URL, the same bearer key, the same rate limits.
https://api.whoot.me/v1/{tenantSlug}. Every endpoint below sits under /sms-lists.api:sms_lists:read - List and retrieve recipient lists and the numbers on them.api:sms_lists:write - Create, update, and delete lists; add, schedule, and remove numbers.usage - Read recipient headroom against your SMS bundle at GET /usage.403 SMS_PLAN_REQUIRED.Two independent windows decide whether a stored number is actually texted, and both are evaluated at the moment an alert is sent rather than when you write the record.
starts_at and ends_at on the list itself. Use it for a list that only exists for an event or a season. Outside the window the list is scheduled or expired and is skipped entirely, even if an operator picks it.starts_at and expires_at on each number. Use it for someone who is only on site for part of the period the list covers: a day-shift steward, a contractor booked for one week, a delegate attending two days of a five-day conference.Both ends of both windows are optional and independent. A number whose starts_at is in the future is stored, returned with status scheduled, counted separately in the dashboard, and is not a recipient until that moment passes. That is what makes it safe to push next season's fixtures today.
# A roster for a match three weeks out. Nothing on it can be texted before kick-off.
curl -X POST https://api.whoot.me/v1/acme/sms-lists \
-H "Authorization: Bearer wh_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Fixture 2026-09-05 stewards",
"external_id": "fixture-88213",
"starts_at": "2026-09-05T09:00:00Z",
"ends_at": "2026-09-05T23:00:00Z"
}'Lists and members both accept an external_id — your booking reference, event id, or employee number. Anywhere a list or member id appears in a path you can send ext:<external_id> instead, so your system never has to store ours.
A member's external_id is unique per list, so you can correct a phone number without the person losing their place: PATCH /sms-lists/ext:fixture-88213/members/ext:booking-4471 with a new e164.
Use POST /sms-lists/{list}/members to add or update up to 500 numbers in one call. It is idempotent on the phone number, so re-sending a roster updates labels and windows rather than creating duplicates. A field you do not mention keeps the value it already had — send an explicit null to clear a label or either end of a window.
curl -X POST https://api.whoot.me/v1/acme/sms-lists/ext:fixture-88213/members \
-H "Authorization: Bearer wh_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"members": [
{ "e164": "+447700900123", "label": "Gate C lead", "external_id": "booking-4471" },
{ "e164": "+447700900124", "label": "Day shift",
"starts_at": "2026-09-05T09:00:00Z",
"expires_at": "2026-09-05T17:00:00Z" }
]
}'There are two ways to sync, and which one you want depends on whether your system can produce the whole roster in a single request.
PATCH /sms-lists/{list}/members with add and remove arrays, up to 500 entries each. A remove entry may be a phone number, a member id, or ext:<external_id>. Use this for change feeds and for lists too large to send at once.PUT /sms-lists/{list}/members with the complete members array, up to 1000. Anything on the list and not in the array is removed. Use this when your system is the sole owner of the list and can state the whole truth in one call.PUT with an empty members array would clear the list, so it is refused unless you also send "allow_empty": true. A list holding more than 20,000 numbers cannot be replaced in one request at all — use the incremental PATCH.When someone replies STOP they are opted out across every list in the workspace. Pushing that number again — on the same list or a new one — carries the opt-out onto the record rather than clearing it, and the API returns the member with status opted_out.
There is no way to re-subscribe someone through this API. Only an inbound START from their own handset, or a deliberate re-add by an administrator in the dashboard, puts them back in scope. A roster sync cannot undo a withdrawal of consent by accident.
List and member responses carry a derived status, so you never have to reimplement the window arithmetic. Lists are permanent, scheduled, active, or expired. Members are active, scheduled, expired, or opted_out.
A list also reports member_count alongside active_member_count, scheduled_member_count, and opted_out_count. Those numbers diverge as soon as you push a roster ahead of its date, which is exactly when you want to see the difference between what is stored and what would be texted right now.
GET /sms-lists/{list}/members is paginated with startIndex and count (maximum 200) and filterable with status and a q substring search over number and label.
GET /sms-lists - Lists for the workspace, with counts. Supports q, external_id, status, startIndex, count.POST /sms-lists - Create a list. name is required; external_id, starts_at, and ends_at are optional.GET /sms-lists/{list} - Retrieve one list.PATCH /sms-lists/{list} - Rename, re-time, or set external_id. A future ends_at reopens an expired list, though its members were removed when it expired, so re-push them.DELETE /sms-lists/{list} - Delete the list and its members.GET /sms-lists/{list}/members - Paginated members.POST /sms-lists/{list}/members - Add or update up to 500 members.PATCH /sms-lists/{list}/members - Incremental add and remove.PUT /sms-lists/{list}/members - Replace the whole roster.PATCH /sms-lists/{list}/members/{member} - Update one member's number, label, window, or external_id.DELETE /sms-lists/{list}/members/{member} - Remove one member.GET /usage - Seat capacity plus sms_recipients headroom against the bundle.GET /v1/acme/sms-lists/ext:fixture-88213/members?status=scheduled&count=50
Authorization: Bearer wh_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
HTTP/1.1 200 OK
Content-Type: application/json
{
"list": { "id": "0fcb33a8-9f3d-4a53-8127-64e7d22f65f5", "external_id": "fixture-88213", "name": "Fixture 2026-09-05 stewards" },
"members": [
{
"id": "7e1f5dd7-8e4d-4e11-a9bb-9a86f2d58e2c",
"external_id": "booking-4471",
"e164": "+447700900124",
"label": "Day shift",
"starts_at": "2026-09-05T09:00:00.000Z",
"expires_at": "2026-09-05T17:00:00.000Z",
"status": "scheduled",
"opted_out_at": null,
"opted_out_reason": null,
"created_at": "2026-08-12T09:14:02.881Z"
}
],
"total": 1,
"start_index": 1,
"count": 1
}Register an endpoint with the webhooks scope to receive sms_list.created, sms_list.updated, sms_list.deleted, sms_list.members_added, and sms_list.members_removed. Payloads identify the list and carry counts; they never repeat the phone numbers back to you. They fire for changes made through this API — changes an administrator makes in the dashboard do not raise them.
Errors are JSON with a stable code and a human-readable error. The ones worth handling explicitly:
SMS_PLAN_REQUIRED (403) - The workspace has no SMS recipient bundle.INSUFFICIENT_SCOPE (403) - The key is missing api:sms_lists:read or api:sms_lists:write.LIST_ALREADY_EXISTS (409) - Duplicate list name or external_id.INVALID_E164 (400) - A number was not in full international form, for example +447700900123.DUPLICATE_E164 (400) - The same number appeared twice in one request.EXPIRES_AT_IN_PAST / ENDS_AT_IN_PAST (400) - A window cannot be closed retroactively; delete the record instead.INVALID_WINDOW (400) - The end of a window is not after its start.TOO_MANY_MEMBERS (413) - Batch limit exceeded; split the roster.EMPTY_REPLACE_NOT_CONFIRMED (400) - A PUT would have cleared the list without allow_empty.OPT_OUT_LOOKUP_FAILED (503) - Opt-out state could not be confirmed, so nothing was written. Retry.RATE_LIMITED (429) - Back off for retry_after_seconds.