Scheduled messages
To send the same message on a repeating schedule (every morning at 8:00, every Monday, the 1st of each month), use a recurring schedule instead of queueing one message per day.
Pass scheduled_at (ISO timestamp or epoch ms, up to 30 days out) to
POST /v1/messages and the message is queued instead of sent:
# Tomorrow at 15:00 UTC (GNU date, then macOS date). Up to 30 days ahead.
SEND_AT=$(date -u -d tomorrow +%Y-%m-%dT15:00:00Z 2>/dev/null || date -u -v+1d +%Y-%m-%dT15:00:00Z)
curl -X POST https://api.jointextkit.com/v1/messages \
-H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+15005550006",
"body": "Reminder: your appointment is tomorrow at 3pm.",
"scheduled_at": "'"$SEND_AT"'"
}'The response is 201 with a scheduled_message, not a Message:
{
"id": "job_1791817200000a1B2c3D4e5",
"object": "scheduled_message",
"to": "+15005550006",
"from": "+15005550100",
"body": "Reminder: your appointment is tomorrow at 3pm.",
"scheduled_at": "2026-10-12T15:00:00.000Z",
"run_at": "2026-10-12T15:00:00.000Z",
"status": "queued"
}| Field | Notes |
|---|---|
id | The job_... id. Use it to cancel. It is not a message id: the message gets its own msg_... id when it is sent. |
object | Always scheduled_message. |
to, from | Both numbers in E.164. |
body | The text that will be sent. |
scheduled_at | When it will be sent, as an ISO timestamp in UTC. The same name as the request field. |
run_at | Deprecated alias of scheduled_at: the same value under its older name, kept so existing code keeps working. Read scheduled_at. |
status | queued while it waits. A cancel response says canceled. |
customer_id | Present only when the send is attributed to a customer. |
batch_id | In list responses only. null for a message scheduled this way. |
When it sends
The queue is worked through once a minute. A message goes out on the first
pass after scheduled_at, so expect it up to a minute after the time you asked
for, or later when many sends are due at once.
scheduled_at is an ISO timestamp string or a number of milliseconds since
the epoch. Put a Z or a UTC offset on the string so the time is
unambiguous. It must be in the future and at most 30 days ahead.
With the SDKs
import { TextKit } from 'jointextkit';
const sms = new TextKit(process.env.TEXTKIT_API_KEY);
const job = await sms.messages.schedule({
to: '+15005550006',
body: 'Reminder: your appointment is tomorrow at 3pm.',
sendAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // tomorrow
});
console.log(job.id, job.scheduled_at);from datetime import datetime, timedelta, timezone
from jointextkit import TextKit
client = TextKit() # reads TEXTKIT_API_KEY
job = client.messages.schedule(
to="+15005550006",
body="Reminder: your appointment is tomorrow at 3pm.",
send_at=datetime.now(timezone.utc) + timedelta(days=1), # tomorrow
)
print(job["id"], job["scheduled_at"])sendAt and send_at take a date object or an ISO string. In Python a
datetime without a timezone is read as UTC, and from_ is optional while
your account has one number. The SDKs cover scheduling only; listing,
retrieving and canceling are plain HTTP calls.
Unlike messages.send, schedule does not add an idempotency key for you.
With plain HTTP, send an Idempotency-Key header to make a retry safe. The
schedule time is part of the payload, so a retried request never queues a
second message (Idempotency).
The schedule reserves nothing
Scheduling checks the shape of the request and nothing else. Everything that decides whether a message may go out is checked again at send time:
- A recipient who opts out between scheduling and sending is skipped. No message is created and no event fires.
- Your quota is taken when the message sends, not when you schedule it. A schedule does not hold quota.
- Content screening and the other send checks run at send time.
- If the
fromnumber has left your account by then, nothing is sent.
A scheduled send that fails one of these checks is dropped. It is not tried
again, and there is no response to carry the error, so schedule only what an
immediate send would accept. A temporary carrier problem is different: the
message is created as queued and gets the normal
automatic retries.
Events and the message
Scheduling fires no event. When the message sends you get the same events as
an immediate send: message.sent, then message.delivered or
message.failed (Webhooks). The message's
timeline starts with accepted at the moment you scheduled it, and its
sent_by is API. To find the message, use the message_id on the
event or list messages by to.
Sandbox
A scheduled message is sent in the mode of its from number. From a sandbox
number it is simulated and never billed, and the
magic numbers behave as they do for
an immediate send.
Listing
GET /v1/scheduled_messages
curl https://api.jointextkit.com/v1/scheduled_messages \
-H "Authorization: Bearer ssms_sk_test_YOUR_KEY"{
"data": [
{
"id": "job_1791817200000a1B2c3D4e5",
"object": "scheduled_message",
"to": "+15005550006",
"from": "+15005550100",
"body": "Reminder: your appointment is tomorrow at 3pm.",
"scheduled_at": "2026-10-12T15:00:00.000Z",
"run_at": "2026-10-12T15:00:00.000Z",
"status": "queued",
"batch_id": null
}
],
"has_more": false,
"next_cursor": null
}The list holds messages that are still waiting, soonest first. A message
leaves it as soon as it is sent, dropped or canceled. There is one page of up
to 1,000 messages; has_more is always false. The list covers the whole
account, whichever key you call it with. Recipients of a scheduled
batch are not included; a batch lists its own. A
restricted key needs the messages:read scope. The console shows the same
list under Scheduled, with a Cancel button on each row.
Retrieving one
GET /v1/scheduled_messages/{id} returns one pending scheduled message.
It answers 404 once the message has been sent or canceled: from then on
it is a message, and you read it with GET /v1/messages.
Canceling
DELETE /v1/scheduled_messages/{id}
curl -X DELETE https://api.jointextkit.com/v1/scheduled_messages/job_1791817200000a1B2c3D4e5 \
-H "Authorization: Bearer ssms_sk_test_YOUR_KEY"{ "id": "job_1791817200000a1B2c3D4e5", "object": "scheduled_message", "status": "canceled" }A cancel either wins or loses as a whole: a message that is already being
sent cannot be canceled, and a canceled message is never sent. A restricted
key needs the messages:send scope. To send one message to many people
later, schedule a batch instead.
Errors
| Status | Code | When |
|---|---|---|
400 | invalid_request | scheduled_at is not a future ISO timestamp or epoch ms, or is more than 30 days ahead (param is scheduled_at). Also a bad to, from, body or customer_id. The body may be 1 to 1600 characters. |
400 | mms_not_enabled | The request has a media field. |
403 | forbidden | from is not a number on your account. |
403 | insufficient_scope | A restricted key without the scope the call needs. |
404 | not_found | On cancel: no pending scheduled message has that id. It may have been sent or canceled already. |
409 | idempotency_conflict | The Idempotency-Key was already used with a different payload. |
409 | idempotency_in_progress | The first request with this key is still running. Retry after Retry-After seconds; the SDKs do. |
Every code is explained in Errors.