The scheduled-send lifecycle
A message with a future send time does not sit on an in-memory timer or a one-off background job. It is persisted in thescheduled state with its fire time stamped on the row, and a continuously running drain picks it up the moment that time arrives. This page explains that mechanism — why the row survives restarts, which paths can remove it before it fires, and what happens when a time-based gate (quiet hours, a send window) interferes at fire time.
Per-endpoint details (request fields, response codes) are in the Messaging API reference; the full state map is in the message status lifecycle reference. This page is the concept the API surface assumes.
Acceptance — sending with a send time
Every channel send endpoint accepts a send-time field in the request body (the data model names itsend_at; the per-channel endpoints accept it as scheduled_at). An ISO-8601 instant in the future means “hold this message and send it then”; a past instant or a missing field means “send now.”
When the instant is future, the send validates like an immediate send — the recipient, template, sender, and channel are checked up front — and the row persists with status scheduled and its fire time stored on it. The response is a 202 acceptance with that status, so you know the platform, not your process, now owns the timing. Persisting the row is what makes scheduling durable: after acceptance the platform dispatches the message at fire time even if your calling process is long gone.
A scheduled time is validated at acceptance: it must be a future instant, and it cannot be more than 35 days ahead. Both rules reject with 422 VALIDATION_ERROR on the create — and on a later re-schedule — so you are never surprised by a row that can never fire, and no row can pin itself into the scheduled scan forever.
The scheduled state and its three exits
A scheduled row is inherently before the gate chain — the message has been accepted, but the gates that decide whether it may dispatch (quiet hours, suppression, caps) run when the time comes, not when you call the API. Only three paths can take a row out of scheduled (this is the state contract the status lifecycle reference defines and the delivery lifecycle names):
Anything but those three paths is impossible by construction: the drain, your cancel call, and the row’s own validity window are the only actors that can touch a still-scheduled row.
The drain — how fire time is found and dispatched
The platform does not rely on a row being in memory at fire time. A scheduled row carries its fire time in a dedicatedscheduled_at column indexed specifically for drain queries (the index covers rows where a fire time is set), so finding “everything due right now” is an indexed lookup rather than a table scan. Campaign step executions keep the same shape — a per-recipient scheduled_at plus a pending-only drain index — so envelope sends and campaign drips share one timing mechanism.
A scheduled-dispatch scheduler runs continuously against every tenant’s messages. Each pass promotes at most a bounded batch of overdue rows scheduled → queued, oldest fire time first, with row-locking that skips rows another worker is touching — so an overdue backlog drains in ordered slices instead of one lock-everything statement, and two overlapping scheduler replicas never pick up the same row. Promoted rows then enter the same claim-and-dispatch path immediate sends use.
The same drain mechanism also recovers from itself: promotion is bounded per pass so a large backlog rates-matches into the dispatch queue instead of stranding, and a separate stuck-row recovery sweep force-advances a row the drain somehow skipped after a per-status grace. The practical effect for you: a due message dispatches at its fire time in the normal case, and self-heals within minutes even on an infrastructure hiccup.
Edit, reschedule, and cancel before dispatch
Until the drain promotes your message toqueued, it is still changeable — you hold a live handle to a parked send:
- Edit the body and/or fire time with
PATCH /messages/:id. Supply a new body, a new send time (still future, still within 35 days), or both. The update lands on the still-scheduledrow and the drain simply finds it at the new time. - Read out what’s parked with
GET /messages/scheduled, narrowing by recipient, channel, or a fire-time ceiling. - Cancel one row with
POST /messages/:id/cancel, or cancel a batch withPOST /messages/cancel-scheduledby explicit ids or by a(to, scheduled_before, channel)filter.
scheduled at the instant you touch it. If fire time already arrived and the drain promoted it, the edit or cancel returns 409 MESSAGE_NOT_EDITABLE / 409 MESSAGE_NOT_CANCELLABLE instead of silently acting on a message that has moved on. Treat 409 as “this row left the scheduled state — reconcile against its current status,” not as a retryable error.
Quiet hours and timezones at fire time
The critical property: gates evaluate at dispatch, not at request. A send that passed quiet hours when you scheduled it is re-evaluated when the drain fires, so acceptance is never grandfathered into a window that has since closed. Three interactions matter when the drain reaches your row:- Marketing SMS/MMS to US recipients re-check the TCPA quiet-hours window (21:00–08:00 recipient-local) at dispatch. If fire time lands inside the window, the row is re-scheduled, not sent: the drain stamps a new fire time at the next 08:00 recipient-local (DST-aware) and returns the row to
scheduled, from which the next drain pass fires it at window open. - Marketing email defers the same way against your org-level quiet-hours window when you enabled one. A misconfigured (degenerate) window fails open so a drip is never stuck re-deferring forever.
- Send-window optimization is an acceptance-time adjustment: with the send-window send type, the fire time you asked for is treated as the earliest acceptable instant and, if it falls outside your configured window, is shifted forward to the next window open at acceptance — before the row ever parks — so the drain needs no special handling.
Which channels support scheduling
Scheduling is channel-independent by construction: the send endpoint for any messaging channel accepts the send-time field, the row parks inscheduled, and one drain serves every channel the same way. SMS, MMS, WhatsApp, RCS, email, and the rest of the messaging channels all flow through the same mechanism — a scheduled WhatsApp template send, a scheduled email with attachments, and a scheduled SMS are identical at the state-and-drain layer.
What is genuinely per-channel is only what the drain re-checks at dispatch: the TCPA quiet-hours re-check applies to marketing SMS and MMS because that is where the regulation bites; email quiet hours apply to marketing email when you enabled an org window; channels with no time-based gate simply dispatch. Voice does not use this mechanism — outbound calls have their own call lifecycle and TCPA federal voice guard.
What to branch on
status: "scheduled"on the send response — the platform owns the timing from here; store the message id for later edit/cancel.- A
202acceptance with a future send time — validation already happened; a later409on edit/cancel means fire time won the race. 422 VALIDATION_ERRORat schedule or re-schedule time — the send time was past or beyond 35 days; correct and resubmit.- The webhook sequence — subscribers see the parked row created with
status: "scheduled", then a created event withstatus: "queued"when the drain promotes it, then the normal terminal outcome. A cancelled row emits no event; pollGET /messages/:idif you need to observe a cancel.
See also
- Delivery lifecycle — the states a message takes after the drain fires it
- Message status DAG — every legal transition off
scheduled - Send gating and quiet hours — the dispatch-time gate chain the drain walks
- Quiet hours configuration — window, timezone, and voice carve-outs
- Data model — time formats and the send-time field shape