Skip to main content

Troubleshooting: a scheduled message that never fired

You scheduled a send with send_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 scheduled with a send_at time visibly in the past, and no message.sent or terminal webhook ever arrives.
  • GET /api/v1/messages/:id returns status: "scheduled" long after the fire time passed.
  • For a scheduled push send: GET /api/v1/push/scheduled/:id still 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

  1. Re-request the timeline. Read GET /api/v1/messages/:id (or the scheduled-push read endpoint) and compare the stored send_at UTC instant against an accurate clock. A future fire time means the page does not apply yet — wait for it.
  2. 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.
  3. Verify a cancellation did not move the row. A cancel/cancel-scheduled call that returned 409 means the drain already won the race; a successful cancel is terminal and you should not expect a fire.
  4. Only then escalate. If the row sits at scheduled more than a few minutes past its send_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:
Paste the message id alongside the row, the 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_at request 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/:id output) with the stored send_at in 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