The scheduled-sending model
Four pages in these docs each cover one slice of time-managed outbound sending, and none of them is the whole model:- The scheduled-send lifecycle — how a message with a future send time parks in the
scheduledstate and how the drain finds it at fire time. - Outbound send gating — the full gate chain every send walks, with quiet hours as one gate.
- Delivery lifecycle — what happens after a send dispatches, with
scheduledtreated as one status among many. - Message status transition rules — the legal
scheduled → queued | cancelled | expiredtransitions and their webhook flavor.
Two message classes that need time management
Scheduling exists to solve two different problems, and they pull on different knobs in the same model: Update emission. A send whose value comes from firing at a precise instant — appointment reminders, expiry-date renewal prompts, “your parcel ships tonight.” Here the fire time is the product feature, and quiet-hours handling means “firing inside a closed window must be handled on purpose, not at random.” Update emission users typically reject the defer-to-window-open behavior partially (the send must fire then, or it is worthless) — they park with a fast fire-time like “tomorrow at 14:00 recipient-local.” If that instant falls within quiet hours, they either want the send cancelled (so it never goes stale) or explicitly shifted — your own code decides which by pre-computing the recipient-local instant before calling. Daily-part schedules. A campaign or drip that must land “on a weekday, between 09:00 and 17:00, recipient-local” — marketing traffic, reminders where “sometime tomorrow” is acceptable, re-engagement flows. Here quiet hours is a target, not an obstruction: the schedule should land inside the window, and if the requested instant misses it, the platform shifts the fire time to next window-open rather than refuse. Send-window optimization is built for exactly this shape: your requested time is treated as the earliest acceptable; a time outside the window is moved to window-open at acceptance, not fire time. Both classes flow through the same state machine below — the difference is only when the defer happens (at acceptance for send-window, at dispatch for quiet-hours marketing SMS/MMS) and who decides deferral is acceptable.One contract, four moments
Every time-managed send moves through the same four moments, in this order. This is the unified contract the scattered pages document:- Schedule. The send endpoint validates the request (recipient, template, sender, send time ≤ 35 days ahead), stamps a fire time, and returns
202— validation already happened at acceptance, so the row the platform parks is structurally clean. - Park. The row sits in
scheduled, its fire time on a dedicated, drain-indexedscheduled_atcolumn — not an in-memory timer. It remains editable and cancellable until the drain promotes it. - Gate at fire. The gate chain — wallet posture, recipient block lists, duplicate-content policy, frequency caps, throughput caps — runs at dispatch, again. Quiet hours (marketing channels) and, for US voice, the federal TCPA dialing window re-evaluate too. What would accept at request time may still fail at fire time; gates never grandfather.
- Fire or resolve. On a clean gate walk, the row is promoted
scheduled → queuedand joins the normal delivery lifecycle. On a quiet-hours gate refusal (resp.: marketing SMS/MMS to US recipients, marketing email with an org window), the row is re-stamped to the next open instant and staysscheduled. On a terminal fail — operator cancel or expiry window lapse — it resolves tocancelledorexpired. And then the billing feed and rate limiters see exactly one send: the fired one.
Quiet-hours re-parks rather than quietly sends
The property every customer actually wants — never breach quiet hours, and never silently drop the send — is enforced by re-stamping, not refusal, on the channels and classes where that makes sense:- Marketing SMS/MMS to US recipients, TCPA quiet-hours window (21:00–08:00 recipient-local). When the drain prods a parked SMS whose fire time falls in the window, it stamps a new fire time at the next 08:00 recipient-local (DST-aware), and the row stays parked. The send is never quietly dropped.
- Marketing email with an org-configured quiet-hours window. Same shape — re-parked to next window-open. A degenerate (fully closed) window fails open so a drip never re-parks forever.
- Non-marketing email and SMS. These channels either lack time-based gates or default to no gate — they dispatch when the drain prods them.
422 TCPA_FEDERAL_DIALING_WINDOW_BLOCKED, not re-parked. The voice gate lives in TCPA federal voice guard; the send pipeline re-checks it at dispatch for any deferred send, so a campaign voice call scheduled in the evening can’t slip through.
The DST-aware behavior matters for both: the platform resolves recipient-local time by recipientTimezone first, the contact’s stored timezone second, then your org default — see Send-gating concept — timezone resolution and the quiet-hours guide timezone section. The result of this re-park policy is a flagship invariant: a scheduled send resolves to the earliest allowed instant inside the window, never to a fired-inside-closed-window accident and never to a silent cancel.
The bill and the rate limiter see only the fired send
The billing model is deliberately simple here: ascheduled row never bills, a live dispatch-induced wallet check still applies, and the usage-record feed contains only fired sends. Concretely:
- A parked row is not billable. The usage-records model writes one record per sent message. A row that sits in
scheduledfor 23 hours accrues zero wallet debit while it is parked — charges land only when the drain fires the row and the provider accepts it. If you never fire (operator cancel, expiry window lapse), there is no billed event and no usage record to reconcile. - Rate limits apply where the drain prods. API request rate limits (
POST /messages/<channel>) fire at your acceptance call — they cap how fast you park, not how fast the drain fires. But the per-service and per-number throughput (MPS) caps, plus any frequency-cap slot, are claimed by the drain at dispatch-time — see Rate-limit taxonomy family 4 for the per-family error codes and retry posture. - Campaign sends. Campaign drips dispatch through the same drain mechanism as envelope sends. When a burst of scheduled sends fire at window-open, throughput caps (429 +
Retry-After: 1, billed limiter family) throttle the drain’s promoted rows at provider admission — not before parking, so your parked volume is never the blast radius.
What a fire-time failure looks like at each gate
Gates don’t refuse the same way at fire time as at acceptance time. The state it lands in tells you which gate stopped it — the send-gating page’s full branch table covers every code; the ones reachable after fire time are the meaningful ones here:
Three failure modes only you can trigger: operator
POST /messages/:id/cancel moves the row to terminal cancelled and fires no webhook (by design — cancel originates from your own API call), and a validity-window lapse moves it to terminal expired with message.failed. The state-anchor for all of these is message status transition rules — the DAG names scheduled → queued | cancelled | expired as the only three legal exits.
Worked example: a quiet-hour send and a pre-fire cancel
Set the org quiet-hours window 21:00–08:00, make a marketing SMS send at 21:30 recipient-local, and watch the four moments play out. 1. Acceptance (21:30). YourPOST /messages/sms with scheduled_at: "2026-09-01T02:00:00+02:00" (a recipient in Europe/Berlin) validates as a clean send: the recipient is on no block list, the wallet is fine. Returns 202 and the row parks in status: "scheduled" with scheduled_at stamped in UTC. Gate gates bypass at request time — acceptance is never the verdict.
2. Parked (21:30 onward). Subscribers see message.created with status: "scheduled" (same message.created event as an immediate queued send — the status field is the branch point). The row is editable (PATCH /messages/:id) and cancellable (POST /messages/:id/cancel) until the drain prods it down. If the recipient opts in for a send-window (your own daily-part window), that fire time was already shifted to window-open at acceptance; if not, it is honored as-is.
3. Fire time (02:00 recipient-local). The drain finds the row — indexed lookup over scheduled_at ≤ now, oldest first, row-locked so an overlapping replica skips it. At dispatch promotion the gate chain re-walks: recipient block lists, wallet, duplicate-suppression if enabled, frequency-cap slots, MPS caps. Marketing SMS/MMS to a US recipient inside 21:00–08:00 recipient-local would not dispatch here — instead the drain re-stamps scheduled_at to next 08:00 recipient-local and keeps the row parked. But this recipient is in Europe/Berlin and quiet hours do not apply to non-marketing sends or to sends your org never enabled a window for — so the row is promoted scheduled → queued.
4. After fire. From queued the row joins the normal delivery pipeline: sending → sent → delivered. Subscribers see a second message.created (status: "queued") for the promotion, then message.sent, then the terminal event. If instead you had cancelled it at 02:15 (a pre-fire cancel), the row resolves to cancelled, no webhook fires, and the drain never fires it; if you let its 35-day validity window lapse, it resolves to expired with a message.failed event. If at dispatch the wallet paused, the opt-out list caught the recipient, or duplicate-content suppressed the body — the table above names which terminal status and webhook flavor each gate produces.
The point the worked example carries: every acceptance you made — the request, the fire-time, the cancel, the gate walk — is observable through exactly the statuses above, and only one send ever bills.
See also
- The scheduled-send lifecycle — drain mechanics, index shape, and edit/cancel semantics in depth
- Outbound send gating — the full gate chain with default postures and branch-on signals
- Delivery lifecycle — what happens after the drain promotes
scheduled → queued - Message status transition rules — allowed
scheduledtransitions and late-event merge rules - TCPA federal voice guard — why US voice refuses instead of re-parking
- Quiet hours configuration — knob surface and timezone resolution
- Usage records model — the per-record billing feed for fired sends
- Rate-limit and cooldown taxonomy — which limiter fires when, and its error code
- Message status lifecycle reference — per-status semantics and webhook-event map