Troubleshooting: a scheduled message that never fired
You scheduled a send withsend_at (or scheduled_at on the per-channel
endpoints), the clock has now passed that time, and the row still reads
scheduled — or no webhook, no Delivery Log entry, no delivery ever
materialised. The scheduled-send lifecycle
explains the mechanism that should have fired the row; this page is the
triage for when it did not.
The fault splits two ways: either the wall clock has not really reached
fire time for the gate that would promote the row, or a gate held the
row at fire time and refused to clear. The cause table below maps each
split to its check and fix; the decision checklist then tells you when to
escalate.
Symptoms
- The dashboard Delivery Log shows the row in
scheduledwith asend_attime visibly in the past, and nomessage.sentor terminal webhook ever arrives. GET /api/v1/messages/:idreturnsstatus: "scheduled"long after the fire time passed.- For a scheduled push send:
GET /api/v1/push/scheduled/:idstill shows the row as scheduled past its fire time, and no push webhook fired.
Cause table — wall-clock vs held gate
All of the held-gate branches are tenant-owned controls — the row holds
because a control you configured has not cleared, not because the platform
lost the row. Only after every one of these checks passes do you treat the
row as a platform defect.
From an error code to this page
One scheduled-send error has a dedicated code worth naming:
Every other scheduled message surface routes its edit/cancel race through
the generic
409 MESSAGE_NOT_CANCELLABLE — a cancel or PATCH that
returned 409 means the row left scheduled, which narrows the diagnosis
away from a never-fired row and toward the post-dispatch lane. When the
code does not map here, fall back to the
scheduled-send lifecycle concept
timeline: a scheduled → queued → sending → sent ladder, where a
cancelled or expired terminal closes the row.
Decision checklist
- Re-request the timeline. Read
GET /api/v1/messages/:id(or the scheduled-push read endpoint) and compare the storedsend_atUTC instant against an accurate clock. A future fire time means the page does not apply yet — wait for it. - Verify a tenant gate has moved. Check the held-gate rows in order: quiet hours, compliance profile link, sender pool resolution. Confirm the gate you fixed actually cleared — a row the gate still holds past fire time is normal.
- Verify a cancellation did not move the row. A
cancel/cancel-scheduledcall that returned 409 means the drain already won the race; a successful cancel is terminal and you should not expect a fire. - Only then escalate. If the row sits at
scheduledmore than a few minutes past itssend_at, no gate in the table holds, no cancel raced it, and it still never promoted, that is a drain-side defect — open a ticket.
Full-error sample to paste in a ticket
A held row past fire time looks like this on the wire:send_at, and the clock on
your side, so support sees the hold precisely.
Fixes not to try
- Do not bulk re-send the same
send_atrequest without diagnosing the gate. If a quiet-hours or compliance gate is holding the original row at fire time, the duplicate inherits the same hold and both rows park; the moment the gate clears you deliver twice. Cancel the original (POST /api/v1/messages/:id/cancel) before you recreate a send you no longer trust, and only after the gate check comes back clean. - Do not re-fire a cancel-scheduled batch to unstick a row. A batch
cancel (by ids or
(to, scheduled_before, channel)) that 409s on one member means that member already promoted; re-issuing the batch does not un-cancel the rest. - Do not treat a missing webhook as evidence the row never fired. The drain promotes the row synchronously with the webhook fan-out; a subscriber that dropped the delivery does not stop the dispatch. Read the row — do not drive your diagnosis off the webhook alone.
What to send support
When the checklist ends at escalation, bundle:- The message id and the full row (
GET /api/v1/messages/:idoutput) with the storedsend_atin UTC. - The channel, sender, and (if a pool was routed) the pool id.
- The gate you already ruled out — quiet-hours window, compliance profile verification, sender pool membership — and the timestamp of the clock you compared against.
- The response code and id of any cancel or edit attempt
(
meta.request_id) so support can trace the row’s last transition.
See also
- The scheduled-send lifecycle — the park, drain, and dispatch mechanics this page assumes
- Message parked before sending — the
pending state table for the other pre-send holds, including
scheduled - Message stuck in queued — the owner of the
queuedlane, for a row that promoted but never dispatched - Send gating and quiet hours — the full dispatch-time gate chain the drain walks
- Sender resolution and pool errors — the sender-resolve branch’s sibling codes
- Error Code Reference — the fallthrough for any code this page does not name