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 sending for > 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.sent to webhooks via after().

reconcile-registrations

  • Picks up orphan brand/campaign rows where status='pending' AND providerBrandId/providerCampaignId IS NULL (POST-time AWS failure).
  • Retries CreateRegistration per row. UPDATE predicate keeps the IS NULL guard 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 = 7 are bumped to failed with providerStatus = 'RECONCILE_TIMEOUT'.

retry-webhooks

  • Selects 100 webhook_deliveries rows where status = 'pending' and next_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 marked failed with reason endpoint 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:

  1. Read usage_records for current period (YYYY-MM), live env.
  2. Compute overage vs. PLANS.pro.limits:
    email_overage = max(0, emails - 10_000)
    sms_overage   = max(0, sms    - 1_000)
    
  3. Emit Stripe meter events:
    • event_name: STRIPE_EMAIL_OVERAGE_METER_EVENT
    • event_name: STRIPE_SMS_OVERAGE_METER_EVENT
    • Payload: { stripe_customer_id, value: <overage> }.

Env required:

  • STRIPE_EMAIL_OVERAGE_METER_EVENT
  • STRIPE_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.