---
title: "Scheduled Sends"
description: "Schedule a text or an outbound call to one contact at a set time, then move, edit or cancel it before it goes out."
---

A scheduled send is one text or one outbound call to one contact at a set
time: an appointment reminder, a follow-up the next morning, a callback. You
create it now; ThunderPhone sends it when it comes due. One list holds every
send, so you can look them up and cancel them in one place.

- **Texts** go out through [`/v1/sms`](/api-reference/sms) rules: an active,
  SMS-capable number on your connected Twilio or Telnyx account.
- **Calls** are placed like [`POST /v1/call`](/api-reference/outbound-calls)
  with a saved agent, and need the same outbound-enabled `from_number` and
  [TCPA confirmation](/api-reference/outbound-calls#outbound-tcpa-confirmation).
- A send is checked when you create it, so you hear about a problem at once,
  and checked again when it comes due.
- Nothing is sent late. A send that cannot go out before its `expires_at` is
  skipped, never sent afterwards.
- Every send honors your organization's [opt-out list](/api-reference/opt-outs).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/scheduled` | Schedule a text or call |
| `GET` | `/v1/scheduled` | List scheduled sends |
| `GET` | `/v1/scheduled/{send_id}` | Retrieve a scheduled send |
| `PATCH` | `/v1/scheduled/{send_id}` | Move or edit a send that is still `scheduled` |
| `DELETE` | `/v1/scheduled/{send_id}` | Cancel one send |
| `POST` | `/v1/scheduled/cancel` | Cancel every queued send for an `external_id` or a number |

Authenticate with an organization API key. These endpoints are not available
to OAuth clients. In a dashboard session any member can schedule texts, and
scheduling or editing a call requires an admin or owner, the same as
`POST /v1/call`; organization API keys are not limited by role.

## Scheduled send object

```json
{
  "id": "6f1c0b8e-3a52-4c1f-9a0b-2f4f7d8a91c3",
  "type": "sms",
  "status": "scheduled",
  "status_reason": "",
  "status_detail": "",
  "send_at": "2026-11-03T17:00:00Z",
  "expires_at": "2026-11-03T17:30:00Z",
  "from_number": "+15551234567",
  "to": "+14155550199",
  "body": "Hi {{name}}, a reminder of your appointment tomorrow at {{time}}.",
  "agent_id": null,
  "variables": {"name": "Ada", "time": "10:30 AM"},
  "context": "",
  "timezone": "America/Los_Angeles",
  "allow_quiet_hours": false,
  "external_id": "appointment-4815",
  "source": "api",
  "source_call_id": null,
  "consent": {"type": "customer_attests"},
  "simulated": false,
  "sms_message_id": null,
  "call_id": null,
  "sent_at": null,
  "created_at": "2026-11-01T18:02:11.204Z",
  "updated_at": "2026-11-01T18:02:11.204Z"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | string (UUID) | Id of the send |
| `type` | string | `sms` or `call` |
| `status` | string | See [statuses](#statuses) |
| `status_reason` | string | Why a send was `skipped` or `failed`; empty otherwise |
| `status_detail` | string | Readable explanation of `status_reason` |
| `send_at` | timestamp | When it goes out |
| `expires_at` | timestamp | It is never sent after this |
| `from_number` | string | Sending number, E.164 |
| `to` | string | Recipient, E.164 |
| `body` | string | Texts: the template, as stored. `{{name}}` placeholders are filled from `variables` when the text is sent. Empty for calls |
| `agent_id` | integer \| null | Calls: the agent that places the call. Null for texts, or when the agent was deleted |
| `variables` | object \| null | Values for `{{name}}` placeholders: the text's body, or the call's agent prompt as in [call variables](/guides/call-variables) |
| `context` | string | Calls: free text added to the agent's prompt for this call |
| `timezone` | string | The recipient's time zone that quiet hours were checked in; empty when it could not be told |
| `allow_quiet_hours` | boolean | `true` when you chose to send outside quiet hours |
| `external_id` | string | Your own key for the send, such as an appointment id |
| `source` | string | What created it: `api` |
| `source_call_id` | integer \| null | The call that created it, when one did |
| `consent` | object | The consent the send relies on. API sends record `{"type": "customer_attests"}`: your organization attests that it has the contact's consent |
| `simulated` | boolean | `true` for sends created on a test call. They are never sent |
| `sms_message_id` | string \| null | Texts: the [SMS message](/api-reference/sms#retrieve-a-message) it produced |
| `call_id` | integer \| null | Calls: the [call](/api-reference/calls) it placed |
| `sent_at` | timestamp \| null | When it went out |
| `created_at`, `updated_at` | timestamp | ISO 8601 UTC |

### Statuses

| Status | Meaning |
|--------|---------|
| `scheduled` | Waiting for `send_at`. Only a `scheduled` send can be edited or canceled |
| `sending` | Being sent now |
| `sent` | Went out. For a text, the carrier accepted it; for a call, it was placed. See `sms_message_id` or `call_id` for what happened next |
| `canceled` | You canceled it |
| `skipped` | Not sent, on purpose. `status_reason` says why |
| `failed` | Tried and did not go out. `status_reason` holds the send's own error code, such as an [SMS error code](/api-reference/sms#send-a-message) or the call's refusal reason |

`skipped` reasons:

| `status_reason` | Meaning |
|-----------------|---------|
| `expired` | It could not go out before `expires_at`, so it was not sent late |
| `opted_out` | The number is on your [opt-out list](/api-reference/opt-outs) for this channel |
| `quiet_hours` | It came due outside 8am to 9pm in the recipient's time zone, for example after waiting behind other texts |
| `sender_not_ready` | The text's `from_number` can no longer send, for example its messaging registration lapsed |
| `outbound_tcpa_unconfirmed` | A call came due before your organization's TCPA confirmation was recorded |

A send that ends `skipped` or `failed` fires the
[`scheduled.skipped` or `scheduled.failed`](/webhooks/events#scheduled-events)
webhook. A sent text fires the usual `sms.*` events with `scheduled_send_id`
set; a placed call fires the usual call events, and its
[`telephony.complete`](/webhooks/call-complete) event carries `scheduled_send_id`.

## Schedule a send

<CodeGroup>
```bash Text
curl -X POST https://api.thunderphone.com/v1/scheduled \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "sms",
    "send_at": "2026-11-03T09:00:00-08:00",
    "from_number": "+15551234567",
    "to": "+14155550199",
    "body": "Hi {{name}}, a reminder of your appointment tomorrow at {{time}}.",
    "variables": {"name": "Ada", "time": "10:30 AM"},
    "external_id": "appointment-4815",
    "idempotency_key": "appointment-4815-reminder"
  }'
```

```bash Call
curl -X POST https://api.thunderphone.com/v1/scheduled \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "call",
    "send_at": "2026-11-03T10:00:00-08:00",
    "from_number": "+15551234567",
    "to": "+14155550199",
    "agent_id": 12,
    "variables": {"name": "Ada"},
    "context": "Ada asked for a call back about rescheduling her appointment.",
    "external_id": "appointment-4815"
  }'
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | `sms` or `call` |
| `send_at` | timestamp | yes | ISO 8601 with a UTC offset; a time without one is read as UTC. At most 90 days ahead. A time up to a minute in the past goes out on the next run |
| `to` | string | yes | Recipient, E.164 |
| `from_number` | string | yes | Texts: an SMS-capable number on your connected carrier account. Calls: an active, outbound-enabled number of your organization |
| `body` | string | texts | The text, up to 1600 characters and 10 SMS segments. `{{name}}` placeholders are filled from `variables` when it is sent. Texts only |
| `agent_id` | integer | calls | The saved agent that places the call. Calls only |
| `variables` | object | no | Values for `{{name}}` placeholders |
| `context` | string | no | Calls only: free text added to the agent's prompt for this call, up to 20,000 characters |
| `external_id` | string | no | Your own key, up to 128 characters, such as an appointment id. Use it to find or cancel every send tied to it |
| `expires_at` | timestamp | no | Never sent after this. Defaults to `send_at` plus 30 minutes; at most 24 hours after `send_at` |
| `allow_quiet_hours` | boolean | no | `true` sends outside 8am to 9pm in the recipient's time zone. Default `false` |
| `idempotency_key` | string | no | Up to 128 characters, unique in your organization |

Returns `201 Created` with the send. A send created with problems you should
know about has a `warnings` array of `{"code", "message"}`:

| Warning `code` | Meaning |
|----------------|---------|
| `timezone_unknown` | The recipient's time zone could not be told from their number, the agent or your organization, so quiet hours were not checked. Make sure `send_at` suits them |
| `variables_missing` | The text's body has placeholders with no value in `variables`; they are left out |
| `may_expire` | Enough other texts are due by then that this one may reach its `expires_at` before your organization's text rate lets it out. Spread `send_at` times or give it a later `expires_at` |

### Quiet hours

Texts and calls go out only between 8am and 9pm in the recipient's time zone.
The zone comes from the recipient's number, else the agent's
[`timezone`](/api-reference/agents#deployed-fields), else your organization's.
A `send_at` outside those hours is refused with `quiet_hours` and the next
allowed time:

```json
{
  "error": {
    "code": "quiet_hours",
    "message": "send_at is outside 8am to 9pm in the recipient's time zone (America/Los_Angeles). The next allowed time is next_allowed_at. To send anyway, pass allow_quiet_hours: true; your organization then takes responsibility for the timing.",
    "next_allowed_at": "2026-11-03T16:00:00+00:00",
    "timezone": "America/Los_Angeles"
  }
}
```

Pass `allow_quiet_hours: true` to send at that time anyway; your organization
takes responsibility for the timing. When no zone can be found, the send is
created with a `timezone_unknown` warning instead. Quiet hours are checked
again when the send comes due: a send held up past 9pm is skipped with
`quiet_hours` rather than sent late at night.

### Texts

- **Templates.** The body is stored as written and filled from `variables`
  when the text goes out, so a moved appointment needs only a
  [`PATCH`](#move-or-edit-a-send) of `send_at` or `variables`.
- **Opt-out line.** The first text your organization sends a number gets
  "Reply STOP to opt out." added on a new line. A body that already contains
  the word STOP in capitals is sent as written, and later texts to the same number are not changed.
  The body's length is checked with that line included.
- **Pacing.** Scheduled texts share your organization's text rate (30 a minute
  by default) with `/v1/sms`, agent tools and follow-ups. Sends are checked
  every minute; when more texts are due than the rate allows, the rest wait for
  the next minute, oldest `send_at` first, until their `expires_at`.
- A scheduled text is not counted against a call's three-text limit.

### Calls

A scheduled call is placed like `POST /v1/call` with `agent_id`, `variables`
and `context`, under the same checks: [blocked destinations](/api-reference/outbound-calls#blocked-destinations),
balance and [call limits](/api-reference/introduction#call-limits). A call
held back by your concurrent-call, same-number or daily limit stays queued and
is tried again the next minute until its `expires_at`. If the agent is deleted
before the call is due, the send fails with `agent_deleted`.

### Limits

- `send_at` at most 90 days ahead.
- `expires_at` after `send_at` and at most 24 hours after it.
- At most 10,000 pending (`scheduled` or `sending`) sends per organization.

### Idempotency

Send an `idempotency_key` to make retries safe. Repeating a request with the
same key and the same fields returns the original send with `200 OK` and
creates nothing. The same key with different fields returns `409`
`idempotency_conflict`.

### Errors

Errors have the shape `{"error": {"code", "message"}}`. A field that is
missing or the wrong type returns `400` with that field's error instead, such
as `{"send_at": ["This field is required."]}`.

| Status | `code` | Meaning |
|--------|--------|---------|
| `400` | `invalid_number` | `to` is not a valid phone number |
| `400` | `send_at_in_past` | `send_at` is in the past |
| `400` | `send_at_too_far` | `send_at` is more than 90 days ahead |
| `400` | `invalid_expires_at` | `expires_at` is not after `send_at`, or is more than 24 hours after it |
| `400` | `quiet_hours` | See [quiet hours](#quiet-hours) |
| `400` | `body_required` | A text needs a `body` |
| `400` | `body_too_long` | The body, filled in and with the opt-out line, is too long. A `body` over 1,600 characters as written is refused earlier, with a field error on `body` |
| `400` | `agent_required` | A call needs `agent_id` |
| `400` | `invalid_request` | Fields that don't fit the type, such as `body` on a call, or invalid `variables` or `context` |
| `400`–`503` | SMS sender codes | The text's `from_number` cannot send, such as `messaging_not_registered` or `not_sms_capable`. See [SMS](/api-reference/sms) |
| `403` | `outbound_tcpa_confirmation_required` | Calls: your organization's [TCPA confirmation](/api-reference/outbound-calls#outbound-tcpa-confirmation) is missing |
| `404` | `agent_not_found` | `agent_id` is not an agent of your organization |
| `404` | `from_number_not_owned` | Calls: `from_number` is not an active number of your organization |
| `409` | `contact_opted_out` | `to` is on your [opt-out list](/api-reference/opt-outs) for this channel |
| `409` | `idempotency_conflict` | The key was used with different fields |
| `429` | `too_many_pending` | Your organization already has 10,000 pending sends. Cancel some first |

## List scheduled sends

```bash
curl 'https://api.thunderphone.com/v1/scheduled?external_id=appointment-4815&status=scheduled,sending' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

| Parameter | Description |
|-----------|-------------|
| `external_id` | Exact match |
| `to` | Recipient number |
| `status` | One status, or several separated by commas |
| `type` | `sms` or `call` |
| `source` | `api` |
| `send_after` | ISO 8601; sends with `send_at` at or after it |
| `send_before` | ISO 8601; sends with `send_at` before it |
| `order` | `asc` (default, soonest first) or `desc` by `send_at` |
| `limit` | 1–200, default 50 |
| `offset` | Default 0 |

```json
{
  "count": 1,
  "results": [ { "id": "6f1c0b8e-3a52-4c1f-9a0b-2f4f7d8a91c3", "type": "sms", "status": "scheduled", "...": "..." } ]
}
```

## Retrieve a scheduled send

```bash
curl https://api.thunderphone.com/v1/scheduled/6f1c0b8e-3a52-4c1f-9a0b-2f4f7d8a91c3 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

Returns the send, or `404` `not_found` when the id is not in your
organization.

## Move or edit a send

```bash
curl -X PATCH https://api.thunderphone.com/v1/scheduled/6f1c0b8e-3a52-4c1f-9a0b-2f4f7d8a91c3 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "send_at": "2026-11-04T09:00:00-08:00",
    "variables": {"name": "Ada", "time": "9:00 AM"}
  }'
```

You can change `send_at`, `expires_at`, `body` (texts), `variables`,
`context` (calls) and `allow_quiet_hours` while the send is `scheduled`.
Other fields are ignored: to change `to`, `from_number` or the agent, cancel
the send and create a new one.
Moving `send_at` without `expires_at` keeps the deadline the same time after
it. The same time, quiet-hours and body checks as creation apply, and the
response can carry `warnings`. A send that is no longer `scheduled` returns
`409` `not_editable`.

## Cancel a send

```bash
curl -X DELETE https://api.thunderphone.com/v1/scheduled/6f1c0b8e-3a52-4c1f-9a0b-2f4f7d8a91c3 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

Returns the send with `status: "canceled"`. Canceling a canceled send is a
no-op. A send that is already `sending` or finished returns `409`
`not_cancelable`.

## Cancel by external_id or number

```bash
curl -X POST https://api.thunderphone.com/v1/scheduled/cancel \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "appointment-4815"}'
```

Cancels every `scheduled` send that matches. Send `external_id`, `to`, or
both; add `type` (`sms` or `call`) to cancel only texts or only calls.
Sends already going out are not affected.

```json
{ "canceled": 2 }
```
