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:
/v1/messagescurl -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"
}
}'{
"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:
- Within about a minute of
send_at, BlueBot moves the text fromscheduledtoqueuedand firesmessage.queued. - The rules decide whether it can go now. If not, it gets a
holdthat says why and until when, exactly as a text sent at that moment would. - 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.
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.
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.
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:
/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0acurl -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.
Cancel it
/v1/messages/msg_1Hq2LxW9cVbN3mKpRtYs0a/cancelcurl -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
/v1/messagescurl "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 got | It means | Do 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_at | The time has no zone or isn’t a date. | Write it like 2026-11-19T17:00:00-05:00. |
400 invalid_request on expires_at | It’s before send_at. | Give it a time after the text is due. |
409 conflict on a PATCH | The 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:
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' },
);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 builds this into a whole booking flow.