Scheduling a message is the easy half. The half teams actually build tooling for is the one that comes after: something was queued yesterday, the campaign changed this morning, and an operator must see what is still pending, fix the copy, move the fire time, or kill the whole batch before it dispatches. In Devotel Orbit that surface is the scheduled-messages queue — Messages → Scheduled in the dashboard, and /api/v1/messages/scheduled beneath it. This post walks the parity contract it honors, the three operations it supports, where parity deliberately stops, a worked bulk-cancel example, and when scheduling is even the right tool.
The scheduled-messages parity contract
Two Twilio shapes define the contract a migrating team brings with them:
- `Messages.list?Status=scheduled` — list the messages still waiting for their future send time, across channels, with pagination.
- `Message.update` on a `scheduled` message — rewrite the body or move the fire time on a message that has not dispatched yet.
Orbit mirrors both in the tenant's own message schema, and the Scheduled console wires them into a single dashboard page:
GET /api/v1/messages/scheduledis the queue read — filter by recipient (to), channel, or a scheduled-before cutoff. The dashboard page renders exactly this list, capped at 200 rows, refreshed every 30 seconds.PATCH /api/v1/messages/{id}is the edit — acceptsbody,scheduled_at, or both, re-validated with the same constraints the original send enforced (a fire time must be in the future and inside the 35-day scheduling horizon).POST /api/v1/messages/{id}/cancelcancels one row;POST /api/v1/messages/cancel-scheduledcancels many.
The load-bearing property of all four endpoints is the status gate: every write is conditional on the row still being in scheduled. If the platform scheduler has already promoted the row to queued and handed it to the dispatcher, the API returns 409 MESSAGE_NOT_EDITABLE or 409 MESSAGE_NOT_CANCELLABLE. That is the parity contract in action — Twilio's own scheduled-message mutation semantics, where editing or cancelling a message that has already left the scheduled state fails rather than silently no-oping.
The three operations and their audit trail
1. View the queue
The read side answers "what is still out there?" The dashboard page lists the pending rows with the scheduled-for time (rendered in your dashboard timezone), channel, recipient, and a body preview. Filtering narrows by recipient, channel, or fire-before cutoff; the 200-row window keeps the page fast, and the main Messages list with status=scheduled plus cursor pagination covers anything larger.
2. Edit body or scheduled_at
Open Edit on a row and the dialog accepts two fields: the body (1–4096 characters) and the fire time (a datetime-local input pinned to your dashboard timezone, converted to a UTC ISO timestamp at submit). The same rules hold in the API: the new scheduled_at must be in the future and within 35 days, and a body rewrite leaves channel, recipient, sender identity, and template untouched — those are deliberate invariants. Changing who a message goes to is a new send, not an edit; cancel the row and create it.
3. Cancel one row or the whole batch
Cancelling a single row moves it to cancelled, permanently, at zero cost — scheduled rows never bill. The bulk-cancel endpoint is the operationally interesting one: it accepts either an explicit ids array (up to 1000 rows — what the dashboard's header-checkbox selection sends) or a filter shape (to / channel / scheduled_before) for tooling-level sweeps. The update is conditional on status='scheduled', so a row the dispatcher grabbed between your read and your write is left untouched rather than resurrected-failed.
The audit trail on each
All three operations land in the tenant's audit log. Edits and single cancels write per-row audit entries. Bulk cancel writes one summary entry per call — the cancelled count plus the filter shape or id count — rather than one row per cancelled message, keeping audit volume bounded on a 1000-row sweep. Per-row outcomes are still recoverable: the response returns the cancelled_ids, which join to the per-message state. The posture is tenant-owned: the operator who ran the sweep can always demonstrate what was voided, by whom, and with which filter — without leaving the dashboard.
Parity vs non-parity — what deviates, deliberately
Parity is the entry into the lane, not the whole lane. Two intentional deviations sit on top of the Twilio shape:
- Tenant-authored mutation timestamps. Every edit and cancel is stamped and logged under the tenant's own audit identity rather than a platform-side shadow record. The audit-log row names the acting user, the IP, and the exact filter or id set — the posture a compliance review asks for when it asks "who touched this send?"
- Idempotency-safe bulk operations. The bulk-cancel path composes with the platform-wide idempotency discipline a Twilio client does not natively supply: a retrying caller holding an
Idempotency-Keyreplays to the same outcome rather than double-cancelling, and the conditionalstatus='scheduled'update makes the operation naturally replay-safe even without the header. Migration code that used to wrap Twilio's update-in-a-loop with its own dedupe can drop that scaffolding.
What does not deviate: channel parity. The queue is cross-channel by construction — SMS, WhatsApp, and email rows sit in the same list, pass the same status gate, and answer the same edit/cancel verbs. A filter-mode bulk cancel can narrow to one channel, but no operation is SMS-only.
A worked example: pause a campaign three hours before the day restarts
The scenario every operator eventually meets: an outbound campaign fired its morning wave, marketing pauses the rest of the day's sends at 3 PM, and the remaining afternoon batch — already scheduled per recipient — must never fire.
- Read the queue.
GET /api/v1/messages/scheduled?channel=sms&scheduled_before=2026-10-03T23:59:00Zreturns the still-pending afternoon rows. The dashboard equivalent: open Messages → Scheduled, set channel to SMS and the scheduled-before filter to end of day. - Select and bulk-cancel. With the explicit-id form, the client sends
POST /api/v1/messages/cancel-scheduledwith the collectedids(or uses the filter form for the same sweep directly). The header carries the caller'sIdempotency-Key; a network-retry of the same request replays to the same result — the response reportscancelled_countand the exact id list, so a retry does not read as a second, phantom sweep. - Race handling is the contract, not a bug. If the scheduler promotes a handful of rows between the read at step 1 and the write at step 2, those rows simply do not appear in
cancelled_ids; the conditional update left them alone. The client reconciles by comparing its collected id set against the returned one — the delta is precisely "dispatched meanwhile," and nothing was wrongly cancelled. - Audit the sweep. One audit-log entry records the bulk cancel: acting user, filter shape, and count. Joining the returned
cancelled_idsback to the message rows produces the per-recipient void list a downstream pause report needs.
The whole flow takes three API calls and no manual state tracking — the queue read, one bulk-cancel, and one audit join.
When to schedule — and when not to
Scheduled sends are one of three cadence tools, and picking the wrong one shows up later as a queue full of rows nobody wants:
- One-shot scheduled sends (the queue this post covers) fit one-off, fire-at-a-time traffic: appointment reminders, a paused batch, a send-later Inbox reply. The row is self-contained; edit and cancel act on that row and nothing else.
- Cadence templates and drip campaigns fit recurring patterns — a journey step every member takes, a weekly digest. Here the scheduled rows this queue shows are the current materialized instance per member; cancelling a row removes today's instance, but changing the recurrence means editing the campaign, not the message.
- When-ready batches fit traffic that should go when conditions allow rather than at a wall-clock time — the scheduler's gating at fire (quiet-hours evaluation, frequency caps) is the actual control; the parked
scheduled_atis just the earliest candidate.
Two tenant gates sit underneath all three and compose with the queue:
- Quiet hours are evaluated at fire time against the recipient's local time, exactly as the send-time optimization post lays out — a recipient-local window, never a UTC-pinned timer. Rescheduling a row moves its candidate time; it does not bypass the quiet-hours gate the scheduler applies when the time arrives.
- Frequency caps gate at the same point: a recipient over cap when the row fires is deferred, not lost, and the row stays visible in the queue until the cap window passes. Bulk-cancel is the escape hatch when the deferral is wrong for the campaign; the caps themselves stay untouched.
The practical rule: use one-shot scheduling when you know the time, cadence templates when you know the pattern, and let quiet hours plus frequency caps guard the rest — HOLA (highest-open-likelihood adaptive) send-time picks handle the optimization case where even the hour should be learned rather than chosen.
Tenant-owned audit posture
The queue is only as trustworthy as the record it leaves. Orbit's posture is deliberately tenant-owned: every mutation the queue accepts is stamped with the acting user's identity and surface (dashboard or API), edits carry before/after state on the row, and bulk operations write a bounded summary that joins back to per-row outcomes. Nothing about the audit posture depends on trust in the platform's internal logs — the tenant's own audit table is the system of record, and the 409-class rejections are part of the audit surface too: an attempted cancel on an already-dispatched row is a recorded refusal, not a swallowed error. For a tenant under TCPA, DLT, or GDPR review, that record is the difference between "we believe the pause worked" and "here is the sweep, the actor, and the exact cancelled ids."
Frequently asked questions
Whose timezone does "scheduled for" use?
The display timezone is your configured dashboard timezone — the console converts it to UTC at submit, and the scheduler fires on the UTC instant. Completion-side gates (quiet hours, send windows) evaluate against the recipient's local time at fire, independently of what timezone the queue displayed. Store and reason in UTC; display in your own zone.
Can I bulk-cancel across channels?
Yes. The queue and the bulk-cancel endpoint are cross-channel: an explicit-id sweep can mix SMS, WhatsApp, and email rows in one call, and a filter-mode sweep can leave channel unset to match every channel at once. Narrowing to one channel is a filter choice, not a protocol limit.
What does the audit log record for a cancel or edit?
Single-row edits and cancels write per-row audit entries with the acting user and outcome. Bulk cancel writes one summary entry — actor, mode (ids or filter), the filter shape or id count, and the cancelled count — and the API response returns the cancelled id list for per-row reconciliation. Rejected operations (a 409 on an already-dispatched row) are refusals, not silent failures.
What happens if I retry a bulk-cancel after a network failure?
The conditional update (status='scheduled') makes retries safe: rows already cancelled no longer match, so the retry is a no-op on them. If the client sends an Idempotency-Key, the platform replays the first response exactly.
Is there a limit on how far ahead I can schedule?
Yes — 35 days from creation or edit. The same horizon applies to the original send and to any later reschedule, so an edit can never push a row past the window the create path enforced.
Where to go next
- Dashboard: Messages → Scheduled — the cross-channel queue itself; Outbound → Campaigns → Direct send is the schedule-and-send entry point for new outbound traffic with
scheduled_at. - Docs: The Scheduled console for the page walkthrough, Schedule one-off sends with `scheduled_at` for the API field, and the scheduled-sending model for park → gate at fire → fire → bill once.
- API:
GET /api/v1/messages/scheduled,PATCH /api/v1/messages/{id},POST /api/v1/messages/{id}/cancel,POST /api/v1/messages/cancel-scheduled— the four surface shapes behind the page.