# Recurring schedules

Source: https://joinsimplesms.com/docs/schedules
Index: https://joinsimplesms.com/llms.txt

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.

```bash
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](/docs/messages#default-sender). |
| `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. |
| `category` | string | Optional. `marketing`, `transactional` or `otp`, for [quiet hours](/docs/compliance-checks#quiet-hours). Omitted: marketing when the sending number is registered for Marketing, otherwise transactional. |
| `quiet_hours` | string | Optional. `defer` (the default) holds a marketing occurrence that comes due in the recipient's quiet hours until their 8:00am; `block` records it as failed with `quiet_hours` instead. |

Returns `201` with a Schedule object. Pass an `Idempotency-Key` header to
make a retried create safe.

## The Schedule object

```json
{
  "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`, `deferred` (held for quiet hours and sent at the recipient's 8:00am) or `failed`. `last_error` says why for a skip or a failure. |
| `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](/docs/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](/docs/spend-limits), 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 with
  `paused_reason: "recipient_opted_out"`. If they opt back in, resume it.
- **An occurrence fails** (quota reached, sender not registered, spend limit,
  not enough [credits](/docs/credits), carrier error): it is recorded as
  `failed` with 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 with `insufficient_credits` and 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](/docs/webhooks) report this: `schedule.occurrence_skipped`
(`schedule_id`, `to`, `occurrence_date`, `due_at`, `reason`) and
`schedule.paused` (`schedule_id`, `to`, `reason`).

## Pause, resume, change, delete

```bash
# 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_date` or `max_occurrences`. Send the whole `repeat` object.
- `GET /v1/schedules` lists 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](/console/scheduled) page with
their next run and Pause, Resume and Delete.

## With the SDKs

```javascript
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 sent
```

```python
schedule = 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).
- `body` up to 1600 characters.
- Key scopes: `messages:send` to create, change and delete;
  `messages:read` to 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.
