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
- Message stored with
status: "scheduled"andscheduled_atpopulated. - Provider is not called at request time.
- Vercel Cron hits
/api/cron/send-scheduledevery minute:- Claims up to 500 due messages per batch with
FOR UPDATE SKIP LOCKED, stamping themsending— the transient marker that keeps two runners off the same row. The claim also picks up rows stale insendingfor > 5 min and transiently-failedscheduled rows whosenext_retry_athas 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. Afromthat is no longer usable by the org — domain unverified, deleted, rebound to another tenant, or a released number / deregistered sender ID — flips the message tocanceledwith the reason inerror_messageinstead of sending it. The same goes for a message from the org's sandbox sender whoseto,ccorbccis 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, butscheduled_athas 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_centsis deferred, not canceled and not failed — it goes back toscheduledwithscheduled_atpushed forward from its own slot by whole 60-minute intervals (so a ramped cohort keeps its spacing instead of collapsing onto one instant), gets onescheduledstatus event with the reason on its FIRST deferral, and is judged again when it comes due. An audience SMS row on thebroadcaststream 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 to0mid-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-time402 SPEND_CAP_EXCEEDEDstill 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 ascheduled_atcan be deferred at all. - Calls SES/SNS for each.
- Updates status to
sent(orfailed) and firesmessage.sentwebhook.
- Claims up to 500 due messages per batch with
- 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 (scheduled → sending) 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-scheduled401s on every tick and messages sit inscheduledindefinitely. 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.