ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Phone Numbers

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/sms rules: an active, SMS-capable number on your connected Twilio or Telnyx account.
  • Calls are placed like POST /v1/call with a saved agent, and need the same outbound-enabled from_number and 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.

Endpoints

MethodPathDescription
POST/v1/scheduledSchedule a text or call
GET/v1/scheduledList 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/cancelCancel 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"
}
FieldTypeDescription
idstring (UUID)Id of the send
typestringsms or call
statusstringSee statuses
status_reasonstringWhy a send was skipped or failed; empty otherwise
status_detailstringReadable explanation of status_reason
send_attimestampWhen it goes out
expires_attimestampIt is never sent after this
from_numberstringSending number, E.164
tostringRecipient, E.164
bodystringTexts: the template, as stored. {{name}} placeholders are filled from variables when the text is sent. Empty for calls
agent_idinteger | nullCalls: the agent that places the call. Null for texts, or when the agent was deleted
variablesobject | nullValues for {{name}} placeholders: the text's body, or the call's agent prompt as in call variables
contextstringCalls: free text added to the agent's prompt for this call
timezonestringThe recipient's time zone that quiet hours were checked in; empty when it could not be told
allow_quiet_hoursbooleantrue when you chose to send outside quiet hours
external_idstringYour own key for the send, such as an appointment id
sourcestringWhat created it: api
source_call_idinteger | nullThe call that created it, when one did
consentobjectThe consent the send relies on. API sends record {"type": "customer_attests"}: your organization attests that it has the contact's consent
simulatedbooleantrue for sends created on a test call. They are never sent
sms_message_idstring | nullTexts: the SMS message it produced
call_idinteger | nullCalls: the call it placed
sent_attimestamp | nullWhen it went out
created_at, updated_attimestampISO 8601 UTC

Statuses

StatusMeaning
scheduledWaiting for send_at. Only a scheduled send can be edited or canceled
sendingBeing sent now
sentWent 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
canceledYou canceled it
skippedNot sent, on purpose. status_reason says why
failedTried 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_reasonMeaning
expiredIt could not go out before expires_at, so it was not sent late
opted_outThe number is on your opt-out list for this channel
quiet_hoursIt came due outside 8am to 9pm in the recipient's time zone, for example after waiting behind other texts
sender_not_readyThe text's from_number can no longer send, for example its messaging registration lapsed
outbound_tcpa_unconfirmedA 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

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"
  }'
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"
  }'
FieldTypeRequiredDescription
typestringyessms or call
send_attimestampyesISO 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
tostringyesRecipient, E.164
from_numberstringyesTexts: an SMS-capable number on your connected carrier account. Calls: an active, outbound-enabled number of your organization
bodystringtextsThe text, up to 1600 characters and 10 SMS segments. {{name}} placeholders are filled from variables when it is sent. Texts only
agent_idintegercallsThe saved agent that places the call. Calls only
variablesobjectnoValues for {{name}} placeholders
contextstringnoCalls only: free text added to the agent's prompt for this call, up to 20,000 characters
external_idstringnoYour own key, up to 128 characters, such as an appointment id. Use it to find or cancel every send tied to it
expires_attimestampnoNever sent after this. Defaults to send_at plus 30 minutes; at most 24 hours after send_at
allow_quiet_hoursbooleannotrue sends outside 8am to 9pm in the recipient's time zone. Default false
idempotency_keystringnoUp 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 codeMeaning
timezone_unknownThe 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_missingThe text's body has placeholders with no value in variables; they are left out
may_expireEnough 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 variables when the text goes out, so a moved appointment needs only a PATCH 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, 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_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."]}.

StatuscodeMeaning
400invalid_numberto is not a valid phone number
400send_at_in_pastsend_at is in the past
400send_at_too_farsend_at is more than 90 days ahead
400invalid_expires_atexpires_at is not after send_at, or is more than 24 hours after it
400quiet_hoursSee quiet hours
400body_requiredA text needs a body
400body_too_longThe 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
400agent_requiredA call needs agent_id
400invalid_requestFields that don't fit the type, such as body on a call, or invalid variables or context
400–503SMS sender codesThe text's from_number cannot send, such as messaging_not_registered or not_sms_capable. See SMS
403outbound_tcpa_confirmation_requiredCalls: your organization's TCPA confirmation is missing
404agent_not_foundagent_id is not an agent of your organization
404from_number_not_ownedCalls: from_number is not an active number of your organization
409contact_opted_outto is on your opt-out list for this channel
409idempotency_conflictThe key was used with different fields
429too_many_pendingYour 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"
ParameterDescription
external_idExact match
toRecipient number
statusOne status, or several separated by commas
typesms or call
sourceapi
send_afterISO 8601; sends with send_at at or after it
send_beforeISO 8601; sends with send_at before it
orderasc (default, soonest first) or desc by send_at
limit1–200, default 50
offsetDefault 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 }