Skip to main content

Schedule one-off sends with scheduled_at

Every channel send endpoint accepts an optional scheduled_at field in the request body. Send it with a future time and the message waits until then; leave it out, or pass a time that has already passed, and the message dispatches immediately. This page covers everything end-to-end: what the field does, the accepted format, per-channel examples, how to inspect/edit/cancel before dispatch, and the edge cases that bite. For the model behind scheduled sending — park, gate at fire time, fire, bill once — see The scheduled-sending model. For the request/response shapes of every endpoint named here, see the Messaging API reference.

What scheduled_at does vs what the platform does

A scheduled_at value is a park instruction, not a dispatch timer you own:
  1. You pass a future time. The send validates like an immediate send — recipient, template, sender, channel — and the API answers 202 Accepted. That means accepted: the message is not sent yet.
  2. The message persists in the scheduled state with its fire time stamped on the row. It survives restarts and stays editable and cancellable.
  3. A continuously running platform scheduler promotes due rows scheduled → queued and dispatches them through the same claim path immediate sends use. The 202 tells you the platform owns the timing from here; your process does not need to stay alive.
Until the row promotes to queued, you hold a live handle on it: read it, rewrite it, or void it. Once it promotes, those handles return 409 — see Inspect, edit, and cancel below.

Use it per channel

SMS, WhatsApp, Email, Facebook Messenger, and Instagram DM all accept scheduled_at on their dedicated send endpoints. A few payload examples (endpoint base: https://api.orbit.devotel.io/api/v1):

SMS

WhatsApp

Email

Facebook Messenger

Meta’s Messenger Platform addresses recipients by a page-scoped ID (PSID), so to carries a numeric PSID, not a phone number.

Instagram DM

In every case the response is 202 with the message’s id and status: "scheduled". Keep the id; it is your handle for edit and cancel.

Accepted formats and why strict ISO validation broke

scheduled_at accepts either a full ISO 8601 string with a timezone suffix or a partial ISO string without one:
  • "2026-04-16T14:30:00Z" (or +02:00, or any explicit offset) — parsed as that exact instant.
  • "2026-04-16T14:30" — a datetime-local shape with no offset. JavaScript constructs this as local browser time, so what it lands on depends on the caller’s own server or browser.
Validation checks that the value parses to a real date; it does not require a timezone marker. Earlier validation required strict ISO-with-offset, which rejected the datetime-local values HTML pickers produce, so scheduling from a browser form rejected silently at the parse edge. The current rule accepts both forms — but an offset-less value is still ambiguous on the wire. Always prefer an explicit Z or offset when you send scheduling input from your own code. See Browser local time vs UTC below. Two correctness invariants regardless of format: a scheduled time must be in the future at acceptance, and it may not be more than 35 days ahead. A past instant acts like a missing field — the message sends immediately.

Inspect, edit, and cancel before dispatch

All of these surface through the Messaging API reference. Until the platform scheduler promotes the row from scheduled to queued, the row is mutable:
  • List pending scheduled messages. GET /api/v1/messages/scheduled returns every still-scheduled row, earliest fire time first, capped at 200 per call. Narrow with the to, channel, or scheduled_before query parameters. For larger exports, use the generic message list with status=scheduled and a cursor.
  • Read a specific message. GET /api/v1/messages/{id} returns the current status and the stored fire time.
  • Edit body or fire time. PATCH /api/v1/messages/{id} rewrites the message body and/or scheduled_at on a still-scheduled row. The scheduler simply finds it at the new time.
  • Cancel one. POST /api/v1/messages/{id}/cancel transitions a scheduled row to cancelled, permanently. Scheduled rows never bill, so cancel before dispatch is free.
  • Bulk cancel. POST /api/v1/messages/cancel-scheduled voids a set by explicit ids, or by a filter (to, channel, scheduled_before). Useful for a leaky scheduled fan-out.
Every one of those operations is gated on the row still being in scheduled when you touch it. If the scheduler already promoted the row, you’ll get 409 MESSAGE_NOT_EDITABLE or 409 MESSAGE_NOT_CANCELLABLE — treat that as “this message left the scheduled state,” not as a retryable error.

WhatsApp: the 24h window probe runs at fire time

Meta’s 24-hour customer-service window allows free-form (non-template) WhatsApp messages only within 24 hours of the recipient’s last inbound message. For a scheduled send, the send path evaluates that window at fire time, using the recipient pair’s latest inbound at the moment the scheduler dispatches — not the moment you accepted the request. The consequence: scheduling a free-form WhatsApp message does not defer the window check — the check lands when the message actually dispatches. If the recipient’s window will close before your fire time, the free-form send rejects then. Two ways to work with that:
  • Probe before scheduling. GET /api/v1/messages/whatsapp/window-status?to=<phone> reports whether the recipient/sender pair is currently inside the window. Treat a closed window as “use a template.” See the 24h window probe parity allowance and the Messaging API reference for the probe’s exact shape.
  • Use an approved template whose approval doesn’t expire with the window. Templates pass at fire time regardless of the window.
The probe runs against the thread time — the latest inbound message timestamp on that recipient/sender pair — so it cannot be fooled by an unrelated inbound under a different contact linkage.

Browser local time vs UTC

A browser scheduling form typically feeds an <input type="datetime-local"> value — the "2026-04-16T14:30" shape with no offset. JavaScript reads that value as local browser time, which is fine if you schedule in the browser and send what you read verbatim away. It goes wrong when any of the following happen:
  • You store or pass an offset-less string and later reinterpret it as UTC. The same 14:30 means different instants depending on the client’s timezone.
  • You mix browser-collected values with server-computed values without fixing the offset on one side.
Pattern to follow: either append the browser’s offset (-07:00, +02:00, …) or convert to UTC before the value goes on the wire. Never round-trip an offset-less scheduled time through a server as if it were UTC.

Sibling scheduling surfaces

Scheduling one-off sends with scheduled_at is one of several timing surfaces. Use the right one for the shape of what you’re scheduling:
  • Push notifications have their own queue-side scheduling endpoint and management surface — GET/DELETE /api/v1/push/scheduled for pending notification rows. See Push API reference.
  • Quiet hours decide when any send is allowed to fire, independent of how its fire time was set. A scheduled send landing inside an org- or campaign-quiet-hours window re-parks to the next allowed instant (on the channels where re-park applies). See Quiet hours: org-wide channel gates vs. the campaign fallback window.
  • The scheduled-sending model puts all of the above on one conceptual page: park → gate at fire → fire → bill once. See The scheduled-sending model.