Cron Jobs
Registered via vercel.ts. Each route is a standard Next.js GET handler under /api/cron/* that authorizes via the CRON_SECRET env.
| Schedule (UTC) | Path | Purpose |
|---|---|---|
* * * * * |
/api/cron/send-scheduled |
Pick up due scheduled messages and call the provider |
*/5 * * * * |
/api/cron/retry-webhooks |
Re-attempt failed webhook deliveries with exponential backoff |
*/5 * * * * |
/api/cron/probe-health |
Probe Postgres + Redis liveness; write rows to health_probes for the public status page |
*/5 * * * * |
/api/cron/domain-verify-poll |
Poll SES for pending domain DKIM verification; flip status; emit domain.verified webhook |
*/5 * * * * |
/api/cron/sms-verify-poll |
Poll the SMS provider for pending brand / campaign / phone-number registration status |
*/15 * * * * |
/api/cron/reconcile-registrations |
Retry brand/campaign create when the AWS provider call failed at POST time (rows with providerBrandId/providerCampaignId = NULL). Rows older than 7 days flip to failed with RECONCILE_TIMEOUT |
*/10 * * * * |
/api/cron/run-exports |
Drain org_exports queue — upload to Vercel Blob when configured, fall back to in-row JSON |
0 2 * * * |
/api/cron/rollup-stats |
Daily roll-up of messages + message_events into daily_stats |
0 3 * * * |
/api/cron/cleanup-idempotency |
Delete idempotency_keys rows past expires_at |
0 4 * * * |
/api/cron/retention |
Nightly prune: health_probes >30d, audit_logs >365d, daily_stats >180d, delivered webhook_deliveries >30d |
0 6 1 * * |
/api/cron/report-overage |
Monthly: report Pro overage to Stripe meter events |
Authentication
Vercel sends Authorization: Bearer ${CRON_SECRET} on scheduled invocations. Each handler:
function authorized(req) {
const secret = process.env.CRON_SECRET;
if (!secret) return true; // local dev: open
return req.headers.get("authorization") === `Bearer ${secret}`;
}
If CRON_SECRET is unset, the endpoints are open (useful locally). Set it in production.
send-scheduled
- Atomic claim:
UPDATE messages SET status='sending', updated_at=now() WHERE id IN (SELECT id FROM messages WHERE (status='scheduled' AND scheduled_at <= now()) OR (status='sending' AND updated_at < now() - interval '5 minutes') ORDER BY scheduled_at LIMIT 500 FOR UPDATE SKIP LOCKED) RETURNING *. Two cron runners can't see the same row — Postgres hands each a disjoint set. - Stale-claim recovery: rows stuck in
sendingfor > 5 min (runner crashed mid-provider-call) become eligible again on the next tick. Bounded double-send window matches the recovery threshold; SES/SNS p99 is well under 8s so it shouldn't fire normally. - Pre-publish: recipient-suppression recheck — STOP'd between schedule and send → flip to
canceled. - Provider call: SES (email) or SNS (SMS). Test env: skip provider, set
providerMessageId = "test_<id>". - Update predicate includes
AND status = 'sending'so a late update from a crashed runner can't stomp a row already re-claimed and completed. - Fan-out: increment usage + emit
message.sentto webhooks viaafter().
reconcile-registrations
- Picks up orphan brand/campaign rows where
status='pending'ANDproviderBrandId/providerCampaignId IS NULL(POST-time AWS failure). - Retries
CreateRegistrationper row. UPDATE predicate keeps theIS NULLguard so overlapping ticks can't double-stamp a row that was just fixed. - Campaign retries only fire once the bound brand finally has a
providerBrandId. - Rows older than
MAX_RETRY_AGE_DAYS = 7are bumped tofailedwithproviderStatus = 'RECONCILE_TIMEOUT'.
retry-webhooks
- Selects 100
webhook_deliveriesrows wherestatus = 'pending'andnext_attempt_at <= now(). - For each: re-run
attemptDelivery()which POSTs to the endpoint and updates attempt count/status. - If the endpoint row is missing or
enabled = false, the delivery is markedfailedwith reasonendpoint disabled or removed.
Backoff: nextBackoffMs(attempt) returns min(2^attempt, 3600) * 1000 + random(0..1000) ms. Capped at 5 attempts (MAX_ATTEMPTS in webhook-fanout.ts).
cleanup-idempotency
DELETE FROM idempotency_keys WHERE expires_at < now(). Keeps the table small.
report-overage
For each org on the pro plan with a Stripe customer:
- Read
usage_recordsfor current period (YYYY-MM), live env. - Compute overage vs.
PLANS.pro.limits:email_overage = max(0, emails - 10_000) sms_overage = max(0, sms - 1_000) - Emit Stripe meter events:
event_name: STRIPE_EMAIL_OVERAGE_METER_EVENTevent_name: STRIPE_SMS_OVERAGE_METER_EVENT- Payload:
{ stripe_customer_id, value: <overage> }.
Env required:
STRIPE_EMAIL_OVERAGE_METER_EVENTSTRIPE_SMS_OVERAGE_METER_EVENT
Missing either → 501 Not Implemented. Set these in Stripe dashboard → Billing → Meters → Event name, then configure the Pro Stripe subscription with a price that references the meter.