Skip to the page

Sending and receiving

Scheduled texts

Send a text at a time you choose with send_at, change or cancel it until then, and see how quiet hours, pacing and expiry decide when it really goes.

Add send_at to a send and the text waits as a draft until that time. Until it’s due you can change its words, its time or its metadata, or cancel it. When the time comes, it joins the queue like any text sent at that moment.

Dr. Patel’s office books a cleaning for Maria on Thursday at 10:30 AM. The booking system schedules a reminder for Wednesday at 5 PM:

POST/v1/messagesSend a text →
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: appt-7712-reminder" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.",
    "send_at": "2026-11-19T17:00:00-05:00",
    "expires_at": "2026-11-20T10:30:00-05:00",
    "metadata": {
      "appointment_id": "7712"
    }
  }'
Response201A scheduled text
{
  "id": "msg_1Hq2LxW9cVbN3mKpRtYs0a",
  "object": "message",
  "state": "scheduled",
  "to": "+15555550123",
  "hold": null,
  "send_at": "2026-11-19T22:00:00.000Z",
  "expires_at": "2026-11-20T15:30:00.000Z",
  "queued_at": null,
  "metadata": {
    "appointment_id": "7712"
  }
}

Write send_at as an ISO 8601 time with its zone: 2026-11-19T17:00:00-05:00 or 2026-11-19T22:00:00Z. A time with no zone is refused, since BlueBot can’t guess whose 5 PM you meant. Answers always give times in UTC.

A send_at less than 5 seconds away, or already past, isn’t an error: the text is sent now and comes back queued. A booking made after the reminder time still gets its reminder.

Scheduling fires no webhook. The first event for a scheduled text is message.queued, when it becomes due.

When it really goes

send_at is when the text joins the queue, not the second it leaves your phone:

  1. Within about a minute of send_at, BlueBot moves the text from scheduled to queued and fires message.queued.
  2. The rules decide whether it can go now. If not, it gets a hold that says why and until when, exactly as a text sent at that moment would.
  3. The Mac takes it at its next check-in (every 10 seconds) in its turn, leaving your pace between texts.

So a reminder scheduled for 5:00 PM usually arrives a little after 5:00. Texts due at the same time leave in the order they became due, one pace apart: 200 reminders all scheduled for 9:00 AM leave over about an hour at the default pace of 20 seconds. Spread them out if the time matters, or lower the pace in your sending rules. Sending rules

Quiet hours

A scheduled text obeys quiet hours like any other. With the default quiet hours, 9:00 PM to 8:00 AM in your zone, a text scheduled for 7:30 AM is held with quiet_hours and goes at 8:00 AM. If you want texts to go at 7:30, move the end of quiet hours, or turn them off, under Settings → Sending rules. Quiet hours

The same goes for the other holds. If the Mac is asleep at 5 PM, the reminder waits for it to come back. If you’ve reached your new-people limit for the day, a scheduled text to somebody new waits until midnight. Holds

Expiry

A held text doesn’t wait forever. Each text has an expires_at. A text still waiting at that time becomes expired, fires message.expired and never goes.

For a scheduled text, the default expiry counts from send_at: 24 hours after it, or whatever your Expire texts after setting says (1 to 168 hours). A text never expires while it’s still scheduled.

Give a reminder its own expires_at when a late text would be wrong. In the example above, the reminder expires at 10:30 AM on Thursday: if Dr. Patel’s Mac is off from Wednesday afternoon until Thursday noon, Maria gets no reminder for an appointment she has already missed. expires_at has to be after send_at, or the send is 400 invalid_request.

Change it before it goes

While the text is scheduled, PATCH it to change the words, the time, the expiry or the metadata. Maria moves her cleaning to Friday at 2 PM:

PATCH/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0aChange a scheduled text →
curl -X PATCH "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 2 PM. Reply C to confirm.",
    "send_at": "2026-11-20T17:00:00-05:00",
    "expires_at": "2026-11-21T14:00:00-05:00"
  }'

A new send_at without an expires_at moves the default expiry along with it. metadata replaces the whole object. A send_at that is now or past sends the text at once. Once a text is due it can’t be changed, only canceled. Change, cancel or settle a text

Cancel it

POST/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancelCancel a text →
curl -X POST "https://usebluebot.com/api/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancel" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Idempotency-Key: appt-7712-reminder-cancel"

A scheduled or queued text can be canceled. It comes back "state": "canceled" with "cancel_reason": "requested", and message.canceled fires. Canceling a text that’s already canceled answers it again unchanged, so a retry is safe. Once the Mac has taken it, it can’t be called back.

If the person texts STOP before the text is due, BlueBot cancels it for you with cancel_reason: "opted_out".

Find your scheduled texts

GET/v1/messagesList texts →
curl "https://usebluebot.com/api/v1/messages?state=scheduled" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"

The list is newest first by when each text was made, not by send_at. Keep the message id in your own record when you schedule, next to the appointment or the order it’s for, so you can change or cancel the right text later. Putting your record’s id in metadata lets every event about the text find the record too.

You gotIt meansDo next
201 with "state": "scheduled"It goes at send_at.Keep the id to change or cancel it.
201 with "state": "queued"send_at was less than 5 seconds away or in the past, so it’s going now.Check that your clock and the zone in send_at are right.
400 invalid_request on send_atThe time has no zone or isn’t a date.Write it like 2026-11-19T17:00:00-05:00.
400 invalid_request on expires_atIt’s before send_at.Give it a time after the text is due.
409 conflict on a PATCHThe text is already due (queued) or gone.Cancel it if it’s still queued, then send a new one.

In the SDKs

The SDKs take the time as a string or a date:

schedule.ts
const message = await client.messages.send(
    {
        to: '+15555550123',
        text: 'Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.',
        sendAt: '2026-11-19T17:00:00-05:00',
        expiresAt: '2026-11-20T10:30:00-05:00',
        metadata: { appointment_id: '7712' },
    },
    { idempotencyKey: 'appt-7712-reminder' },
);
schedule.py
from datetime import datetime
from zoneinfo import ZoneInfo

eastern = ZoneInfo("America/New_York")
message = client.messages.send(
    to="+15555550123",
    text="Hi Maria, a reminder that your cleaning with Dr. Patel is tomorrow at 10:30 AM. Reply C to confirm.",
    send_at=datetime(2026, 11, 19, 17, 0, tzinfo=eastern),
    expires_at=datetime(2026, 11, 20, 10, 30, tzinfo=eastern),
    metadata={"appointment_id": "7712"},
    idempotency_key="appt-7712-reminder",
)

A Date in TypeScript or an aware datetime in Python is sent with its zone. A naive Python datetime, with no tzinfo, is refused by the SDK before it reaches BlueBot.

The appointment reminders guide builds this into a whole booking flow.