Skip to main content

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 the scheduled 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 it send_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 dedicated scheduled_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 to queued, 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-scheduled row 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 with POST /messages/cancel-scheduled by explicit ids or by a (to, scheduled_before, channel) filter.
All of these race the drain deliberately: each one is gated on the row still being in 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.
Timezone resolution (recipient-provided timezone, the contact’s stored timezone, your org default) applies to these evaluations the same way it does on immediate sends — see the send-gating concept for the full gate chain and the quiet-hours guide for window and timezone configuration. The net behavior: a scheduled send never accidentally delivers inside a recipient’s quiet hours, and it always resolves to the earliest allowed instant rather than disappearing.

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 in scheduled, 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 202 acceptance with a future send time — validation already happened; a later 409 on edit/cancel means fire time won the race.
  • 422 VALIDATION_ERROR at 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 with status: "queued" when the drain promotes it, then the normal terminal outcome. A cancelled row emits no event; poll GET /messages/:id if you need to observe a cancel.

See also