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:

bash
# 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:

json
{
  "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"
}
FieldNotes
idThe job_... id. Use it to cancel. It is not a message id: the message gets its own msg_... id when it is sent.
objectAlways scheduled_message.
to, fromBoth numbers in E.164.
bodyThe text that will be sent.
scheduled_atWhen it will be sent, as an ISO timestamp in UTC. The same name as the request field.
run_atDeprecated alias of scheduled_at: the same value under its older name, kept so existing code keeps working. Read scheduled_at.
statusqueued while it waits. A cancel response says canceled.
customer_idPresent only when the send is attributed to a customer.
batch_idIn 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

js
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);
python
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 from number 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

bash
curl https://api.jointextkit.com/v1/scheduled_messages \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
json
{
  "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}

bash
curl -X DELETE https://api.jointextkit.com/v1/scheduled_messages/job_1791817200000a1B2c3D4e5 \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
json
{ "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

StatusCodeWhen
400invalid_requestscheduled_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.
400mms_not_enabledThe request has a media field.
403forbiddenfrom is not a number on your account.
403insufficient_scopeA restricted key without the scope the call needs.
404not_foundOn cancel: no pending scheduled message has that id. It may have been sent or canceled already.
409idempotency_conflictThe Idempotency-Key was already used with a different payload.
409idempotency_in_progressThe first request with this key is still running. Retry after Retry-After seconds; the SDKs do.

Every code is explained in Errors.