# Scheduled messages

Source: https://jointextkit.com/docs/scheduled
Index: https://jointextkit.com/llms.txt

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](/docs/schedules)
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"
}
```

| 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](/docs/customers). |
| `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

```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](/docs/messages#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](/docs/messages#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](/docs/webhooks#event-types)). 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](/docs/sandbox#magic-destination-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](/docs/broadcasts) 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](/docs/broadcasts) 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](/docs/errors#codes).
