Open in
Scheduled Sends
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/smsrules: an active, SMS-capable number on your connected Twilio or Telnyx account. - Calls are placed like
POST /v1/callwith a saved agent, and need the same outbound-enabledfrom_numberand 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_atis skipped, never sent afterwards. - Every send honors your organization's opt-out list.
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
{
"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 |
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 |
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 it produced |
call_id | integer | null | Calls: the call 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 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 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
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 event carries scheduled_send_id.
Schedule a send
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"
}'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"
}'| 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, else your organization's.
A send_at outside those hours is refused with quiet_hours and the next
allowed time:
{
"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
variableswhen the text goes out, so a moved appointment needs only aPATCHofsend_atorvariables. - 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, oldestsend_atfirst, until theirexpires_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,
balance and 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_atat most 90 days ahead.expires_ataftersend_atand at most 24 hours after it.- At most 10,000 pending (
scheduledorsending) 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 |
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 |
403 | outbound_tcpa_confirmation_required | Calls: your organization's 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 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
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 |
{
"count": 1,
"results": [ { "id": "6f1c0b8e-3a52-4c1f-9a0b-2f4f7d8a91c3", "type": "sms", "status": "scheduled", "...": "..." } ]
}Retrieve a scheduled send
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
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
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
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.
{ "canceled": 2 }