> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# The scheduled-send lifecycle: from a future send_at to dispatch

> How a message carrying a future send time parks in the `scheduled` state, which three paths let it leave that state, how the platform drain picks it up at fire time, and how quiet hours and timezones interact with the drain.

# 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](/api-reference/endpoints/messaging); the full state map is in the [message status lifecycle reference](/api-reference/messages-status-lifecycle). 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](/concepts/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](/api-reference/messages-status-lifecycle) defines and the [delivery lifecycle](/concepts/delivery-lifecycle) names):

| Exit                 | What moves the row                                                                                                      | Result                                                                                                          |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Fire at its time** | The drain promotes `scheduled → queued` once the stored time is at-or-before now, then a worker claims it.              | The row joins the normal [delivery lifecycle](/concepts/delivery-lifecycle) on its way to `sent` / `delivered`. |
| **You cancel it**    | `POST /messages/:id/cancel` moves a still-scheduled row to the terminal `cancelled` state.                              | The drain never fires it. Cancelling fires no webhook, exactly as the delivery lifecycle describes.             |
| **It expires**       | If the message's validity window lapses before the drain promotes it, the row resolves to the terminal `expired` state. | Subscribers fan out a `message.failed` event, as with a late DLR.                                               |

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](/concepts/send-gating-and-quiet-hours) for the full gate chain and the [quiet-hours guide](/guides/quiet-hours-configuration) 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](/concepts/voice-call-lifecycle) and [TCPA federal voice guard](/concepts/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

* [Delivery lifecycle](/concepts/delivery-lifecycle) — the states a message takes after the drain fires it
* [Message status DAG](/concepts/message-status-dag) — every legal transition off `scheduled`
* [Send gating and quiet hours](/concepts/send-gating-and-quiet-hours) — the dispatch-time gate chain the drain walks
* [Quiet hours configuration](/guides/quiet-hours-configuration) — window, timezone, and voice carve-outs
* [Data model](/concepts/data-model) — time formats and the send-time field shape
