Skip to main content

Scheduled messages: Twilio parity recipes

Orbit’s scheduled-message queue mirrors the two Twilio shapes a migrating team usually ports first — Messages.list?Status=scheduled to read what is still pending, and Message.update on a scheduled message to change body or fire time before dispatch. The Scheduled console guide walks the same surface in the dashboard; this page gives the API recipes. All examples assume Authorization: Bearer dv_live_sk_xxxx and JSON bodies. The status gate is uniform: every mutation is conditional on the row still being in scheduled; once the scheduler promotes it, calls return 409 MESSAGE_NOT_EDITABLE or 409 MESSAGE_NOT_CANCELLABLE.

1. List the pending queue — Twilio Messages.list?Status=scheduled parity

Query filters narrow the queue before it returns: For larger sweeps, use the main messages list with status=scheduled and cursor pagination — the same rows, without the 200-row window. Twilio-side mapping: this replaces the Messages.list?Status=scheduled read. The response rows carry the same lifecycle vocabulary (status: "scheduled", scheduled_at, to, channel, body), so a port that consumed the Twilio list shape typically needs only auth and field-name handling changed.

2. Edit body or fire time — Twilio Message.update parity

  • Send body, scheduled_at, or both. Both are re-validated with the create-path rules: body length, a future fire time, and the 35-day scheduling horizon.
  • Channel, recipient, sender identity, and template are not editable on purpose. Changing them is a new send — cancel and create.
  • Unchanged fields are a client-side no-op in the dashboard dialog; raw API calls that change nothing still succeed and leave the row untouched.
Twilio-side mapping: this replaces Message.update against a scheduled message. Same verb, same gate, same refusal modes when the row already left the scheduled state.

3. Cancel one row

Moves the row to cancelled, permanently. Scheduled rows never bill, so a cancelled scheduled send is free end-to-end. A 409 means the row already dispatched — treat it as “this message left the scheduled state,” not a retryable transport error.

4. Bulk-cancel — the sweep recipe

Two modes, one endpoint: POST /api/v1/messages/cancel-scheduled. Explicit ids (what the dashboard selection sends):
Filter mode (tooling-level sweep):
  • ids accepts up to 1000 rows. filter requires at least one of to, channel, scheduled_before — a bare filter is a 422, not an empty success.
  • The update is conditional on status='scheduled', so rows the scheduler picked up between your read and your write are left alone and simply do not appear in the response’s cancelled list.
  • The response names the exact outcome: cancelled_count and the per-row cancelled_ids. Reconcile a race by diffing your collected ids against that list — the delta is precisely what dispatched meanwhile.

Idempotency and retries

Send Idempotency-Key on sweeps your tooling may retry. The conditional update already makes a blind retry safe (cancelled rows no longer match the gate), and the key collapses retry-into-response-replay into one semantics. This is a deliberate extension over the Twilio shape: the loop-and-dedupe scaffolding a Twilio client builds around Message.update is unnecessary here — see idempotency and safe retries for the platform-wide rule.

5. The audit posture

Every mutation above lands in your tenant’s audit log.
  • Single edit / single cancel → one audit entry per row, stamped with the acting user and surface.
  • Bulk cancel → one summary entry per call (mode, filter shape or id count, cancelled count), with cancelled_ids in the API response for per-row joins.
  • Refusals are recorded refusals, not silent failures: a 409 on an already-dispatched row is the audit-visible evidence the queue is honest.
Tenant-owned means the audit table in your schema is the system of record — a TCPA / DLT / GDPR review can show who ran the sweep, with which filter, and which exact ids were voided, without touching platform-internal logs.