Recurring schedules
A schedule sends one message to one number on a repeating wall-clock time: every morning at 8:00, every Monday and Friday at 9:00, the 1st of each month. You create it once; SimpleSMS sends each occurrence.
curl -X POST https://api.joinsimplesms.com/v1/schedules \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550132",
"body": "Good morning. You have got this.",
"repeat": { "frequency": "daily", "time": "08:00", "timezone": "America/New_York" }
}'| Parameter | Type | Notes |
|---|---|---|
to | string | Destination number (E.164, US/Canada). |
from | string | A number you own. Optional when the account has one number for the key's mode, like POST /v1/messages. |
body | string | Up to 1600 characters, the same as a normal send. |
repeat.frequency | string | daily, weekly or monthly. |
repeat.time | string | 24-hour local time, "HH:MM". |
repeat.timezone | string | IANA time zone, e.g. America/New_York. |
repeat.days_of_week | string[] | Weekly only, required: one or more of sun, mon, tue, wed, thu, fri, sat. |
repeat.day_of_month | integer | Monthly only, required: 1 to 31. In a shorter month it runs on the last day. |
start_date | string | Optional. First local date it may run, YYYY-MM-DD. Defaults to today. |
end_date | string | Optional. Last local date it may run (inclusive). |
max_occurrences | integer | Optional. The schedule completes after this many messages have been sent. |
Returns 201 with a Schedule object. Pass an Idempotency-Key header to
make a retried create safe.
The Schedule object
{
"id": "sch_a1B2c3D4e5F6g7H8",
"object": "schedule",
"to": "+14155550132",
"from": "+15005550100",
"body": "Good morning. You have got this.",
"repeat": { "frequency": "daily", "time": "08:00", "timezone": "America/New_York" },
"start_date": null,
"end_date": null,
"max_occurrences": null,
"active": true,
"status": "active",
"paused_reason": null,
"next_run_at": "2026-10-06T12:00:00.000Z",
"last_run_at": "2026-10-05T12:00:00.000Z",
"last_status": "sent",
"last_error": null,
"last_message_id": "msg_a1B2c3D4e5F6g7H8",
"counts": { "sent": 1, "skipped": 0, "failed": 0 },
"test": true,
"created_at": "2026-10-05T11:42:10.000Z",
"updated_at": "2026-10-05T12:00:02.000Z"
}| Field | Notes |
|---|---|
status | active, paused or completed (end_date passed or max_occurrences reached). active is the same thing as a boolean. |
paused_reason | manual (you paused it), recipient_opted_out, repeated_failures or sender_unavailable. null unless paused. |
next_run_at | The next occurrence, in UTC. null unless active. |
last_status | sent, skipped_opted_out, skipped_missed or failed. last_error says why for the last three. |
counts | Messages sent, occurrences skipped (opted out or missed), occurrences that failed. |
test | true for a schedule made with a test key. It only ever sends in the sandbox. |
recent_occurrences | Only on GET /v1/schedules/{id}: the last 10 occurrences with date, due_at, status, message_id and error. |
Every occurrence is a normal send
When an occurrence is due, the message goes through exactly what a request to
POST /v1/messages goes through at that moment: the opt-out list, sender
registration, your quota and spend limit, content
screening. Nothing is reserved in advance, and a refused occurrence costs
nothing.
Each occurrence is an ordinary message with schedule_id set. It appears in
GET /v1/messages (filter with ?schedule_id=sch_...), in the console, and
fires the usual message.sent and message.delivered events.
Time zones and daylight saving
time is a wall-clock time in timezone. 08:00 in New York stays 08:00 in
New York all year; the UTC time in next_run_at moves by an hour when the
clocks change.
- A time that does not exist (clocks jump from 02:00 to 03:00, and the schedule says 02:30): that day it runs the same distance past the jump, at 03:30. It is not skipped.
- A time that happens twice (clocks fall back and 01:30 occurs twice): it runs once, the first time.
A schedule sends at most one message per local calendar day, whatever you edit.
What happens when
- The recipient has opted out. The occurrence is skipped and recorded
(
last_status: "skipped_opted_out"), and nothing is sent. After 3 skipped occurrences in a row the schedule pauses itself withpaused_reason: "recipient_opted_out". If they opt back in, resume it. - An occurrence fails (quota reached, sender not registered, spend limit,
not enough credits, carrier error): it is recorded as
failedwith the error code and the schedule carries on. Credits are drawn when an occurrence fires, not when the schedule is created; one the balance cannot cover fails withinsufficient_creditsand is not charged. After 5 failures in a row it pauses itself (repeated_failures). If the sending number was released it pauses at once (sender_unavailable). - We miss an occurrence. If our scheduler reaches an occurrence more than
1 hour late, the message is not sent late; it is recorded as
skipped_missed. After a longer outage only the most recent occurrence is considered, and only if it is less than 1 hour old. You never get a burst of catch-up messages. - Exactly once. Each occurrence is claimed before it is sent, so it cannot be sent twice.
Two webhook events report this: schedule.occurrence_skipped
(schedule_id, to, occurrence_date, due_at, reason) and
schedule.paused (schedule_id, to, reason).
Pause, resume, change, delete
# Pause
curl -X PATCH https://api.joinsimplesms.com/v1/schedules/sch_a1B2c3D4e5F6g7H8 \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "active": false }'PATCH /v1/schedules/{id}with{ "active": false }pauses and{ "active": true }resumes. Resuming starts from now: nothing that came due while paused is sent.- The same call changes
to,from,body,repeat,start_date,end_dateormax_occurrences. Send the wholerepeatobject. GET /v1/scheduleslists them,GET /v1/schedules/{id}returns one.DELETE /v1/schedules/{id}stops it for good. Messages it already sent stay in your message log.
In the console, schedules are on the Scheduled page with their next run and Pause, Resume and Delete.
With the SDKs
const schedule = await sms.schedules.create({
to: '+14155550132',
body: 'Good morning. You have got this.',
repeat: { frequency: 'daily', time: '08:00', timezone: 'America/New_York' },
});
await sms.schedules.pause(schedule.id);
await sms.messages.list({ scheduleId: schedule.id }); // what it has sentschedule = client.schedules.create(
to="+14155550132",
body="Good morning. You have got this.",
repeat={"frequency": "daily", "time": "08:00", "timezone": "America/New_York"},
)
client.schedules.pause(schedule["id"])Limits
- 25 active schedules per account, 100 stored in total.
- At most one message per local day per schedule (the shortest frequency is daily).
bodyup to 1600 characters.- Key scopes:
messages:sendto create, change and delete;messages:readto list and retrieve.
What is stored
A schedule stores the recipient's number and the message text until you delete it, so it can send the next occurrence. A record of each occurrence (date, outcome, message id; never the text) is kept for the last 90 occurrences. Deleting the schedule deletes both. The messages it sent are ordinary messages and follow normal message retention.