Scheduled Sends

Defer a send to a future timestamp via scheduled_at.

Request

POST /api/v1/emails
{
  "from": "hi@yourdomain.com",
  "to": ["user@example.com"],
  "subject": "Reminder",
  "text": "Your trial ends tomorrow.",
  "scheduled_at": "2026-05-01T12:00:00Z"
}

scheduled_at must be ISO 8601 UTC. If the time is ≤ 1 second from now, the send is treated as immediate.

Behavior

  1. Message stored with status: "scheduled" and scheduled_at populated.
  2. Provider is not called at request time.
  3. Vercel Cron hits /api/cron/send-scheduled every minute:
    • Claims up to 500 due messages per batch with FOR UPDATE SKIP LOCKED, stamping them sending — the transient marker that keeps two runners off the same row. The claim also picks up rows stale in sending for > 5 min and transiently-failed scheduled rows whose next_retry_at has come due. See Cron jobs → send-scheduled.
    • Re-checks what can have changed since the message was accepted: tenant status, recipient suppression (to, plus cc/bcc), the free-plan and per-tenant caps, and the sender. A from that is no longer usable by the org — domain unverified, deleted, rebound to another tenant, or a released number / deregistered sender ID — flips the message to canceled with the reason in error_message instead of sending it. The same goes for a message from the org's sandbox sender whose to, cc or bcc is not a verified member's email, or that belongs to an audience blast — including one scheduled before that rule existed. The sender was authorized when the send was accepted, but scheduled_at has no horizon, so it is checked again at the moment the mail actually leaves.
    • Re-checks the org spend cap too, and answers differently: a row whose units would push month-to-date metered cost past organizations.spend_cap_cents is deferred, not canceled and not failed — it goes back to scheduled with scheduled_at pushed forward from its own slot by whole 60-minute intervals (so a ramped cohort keeps its spacing instead of collapsing onto one instant), gets one scheduled status event with the reason on its FIRST deferral, and is judged again when it comes due. An audience SMS row on the broadcast stream is re-checked against quiet hours as it is re-timed, so a deferral cannot walk a blast into the 21:00–08:00 destination-local window. The first deferral in a batch also fires the 100% spend-cap usage alert, so a frozen queue is not silent. The cap is a ceiling the owner can raise, so the row waits for it. Setting the cap to 0 mid-ramp therefore pauses the ramp (every metered row defers, an hour at a time, until the cap moves or the job is canceled) rather than canceling it. The enqueue-time 402 SPEND_CAP_EXCEEDED still applies to new requests; this is the backstop for rows accepted before the cap started binding, which the period counters never saw. Sends inside a plan allowance carry no metered cost and are never deferred, and neither is a transactional inline send: only a row that carries a scheduled_at can be deferred at all.
    • Calls SES/SNS for each.
    • Updates status to sent (or failed) and fires message.sent webhook.
  4. Test-mode keys skip the provider call; scheduled behavior is the same.

Canceling a scheduled send

DELETE /v1/emails/{id} (scope send:email) or DELETE /v1/sms/{id} (scope send:sms) flips the message to canceled. Returns { "id": "...", "status": "canceled" }.

Cancelable statuses are scheduled, plus a transiently-failed scheduled message still inside the cron's retry cohort (next_retry_at set, attempts under the cap) — refusing that one and then delivering it minutes later would be the worst of both answers. Anything else 409s with NOT_SCHEDULED.

Cancellation races the cron. The UPDATE re-checks the status predicate and confirms a row actually changed, so a message the cron claimed (scheduledsending) between the read and the write 409s with "Message is already being sent and can no longer be canceled" rather than reporting a cancel that didn't happen.

Operational notes

  • Cron cadence is once per minute. Expect up to 60s of jitter beyond scheduled_at.
  • The cron authenticates via CRON_SECRET, and the check fails closed in production. An unset secret does not leave the endpoint open — it rejects every caller, Vercel Cron included, so /api/cron/send-scheduled 401s on every tick and messages sit in scheduled indefinitely. Nothing surfaces to the sender: the POST already returned 201, and a 401 on the tick is an ordinary response the platform counts as a successful invocation. This is the first symptom to check when scheduled sends stop firing. Outside production an unset secret is permissive. See Cron jobs → Authentication.
  • Usage is incremented when the cron sends the message, not when it's scheduled.