Operators

Cron jobs

All crons are declared in apps/saas/vercel.json and authenticated with CRON_SECRET via the Authorization: Bearer ... header. Each cron endpoint uses crypto.timingSafeEqual to verify the token — the cron paths are not anonymously hittable.

Cron inventory

EndpointSchedule (UTC)Purpose
/api/cron/scheduled-scansEvery minute (* * * * *)Dispatch due scheduled-scan runs to Inngest
/api/cron/dispatch-scan-outboxEvery minute (* * * * *)Retry durable scan handoffs to Inngest
/api/cron/dispatch-notification-outboxEvery minute (* * * * *)Retry durable email and webhook deliveries
/api/cron/process-scan-batchesEvery 15 min (*/15 * * * *)Drain provider scan batches and finalize sessions
/api/cron/process-completed-batchesEvery 30 min (*/30 * * * *)Process completed Anthropic Batch API jobs
/api/cron/reset-quotasDaily at 00:00 (0 0 * * *)Reset monthly scan counters for orgs past 28-day window
/api/cron/compose-notification-digestDaily at 12:00 (0 12 * * *)Fold parked alert emails into one daily digest per user
/api/cron/nightly-topic-insights-submitDaily at 04:00 (0 4 * * *)Submit topic-level digest narrative batches
/api/cron/weekly-digest-submitMondays at 13:00 (0 13 * * 1)Submit weekly workspace digest narrative batches
/api/cron/prune-analytics-snapshotsDaily at 02:30 (30 2 * * *)Prune stale analytics_snapshot rows with no narrative value
/api/cron/rollup-cost-ledgerDaily at 05:30 (30 5 * * *)Materialize daily cost-ledger rollups
/api/cron/reconcile-spendDaily at 06:00 (0 6 * * *)Compare recorded spend with provider usage

Most crons fast-fail (under 1 second) — they dispatch work to Inngest rather than doing it inline. That's why we don't hit Vercel's 60s function timeout even on the every-minute crons.

/api/cron/scheduled-scans

Schedule: every minute
What it does: queries the DB for scheduled_scan rows where nextRunAt <= now(). For each due schedule, enqueues a scheduled-scan/due Inngest event with the schedule ID, then bumps nextRunAt based on the cadence.

If this stops:

  • Symptom: no new automated scans appear for users
  • Impact: weekly/daily/monthly scheduled scans don't fire
  • Diagnosis: check Vercel cron logs for HTTP 401 (CRON_SECRET mismatch) or 500 (DB connection issues)

The Inngest worker that processes the events is scheduledScansExecutor in apps/saas/lib/inngest/scheduled-scans.ts.

/api/cron/process-completed-batches

Schedule: every 30 minutes
What it does: finds insight_batch rows whose status is still submitted or in_progress and fires a batch/drain-requested event per org. The processPendingBatches Inngest function then polls Anthropic's Batches API for completion and processes finished batches.

If this stops:

  • Symptom: weekly digest narratives don't arrive on Mondays
  • Impact: AI narratives stop refreshing via the batch lane (on-demand regenerate still works)
  • Diagnosis: check Inngest Cloud dashboard — failed processPendingBatches runs would show in the function run history

/api/cron/compose-notification-digest

Schedule: daily at 12:00 UTC
What it does: market-movement alerts (ALERT_COMPETITOR_SURGE, ALERT_NEW_COMPETITOR_DISCOVERED, ALERT_MENTION_RATE_DROP) are written to the delivery outbox with digestGroup = 'daily' instead of being emailed one at a time. This pass groups the parked rows per user and replaces them with a single outbox row carrying an items[] payload; the every-minute drain sends it. A user with only one parked row gets it released as an ordinary single email instead of a one-item "digest".

Parked rows are excluded from the drain (digestGroup IS NULL is part of both the drain query and the row claim), so there is no race with the every-minute sender and no dependency on this cron winning a clock boundary.

If this stops:

  • Symptom: daily-cadence alert emails stop arriving entirely
  • Impact: notifications are not lost — every alert is still in the user's in-app list the moment it fires, and the parked rows accumulate rather than expiring. The next successful run sends them.
  • Diagnosis: SELECT count(*) FROM notification_delivery_outbox WHERE digestGroup IS NOT NULL AND deliveredAt IS NULL — a number that grows across days means this cron isn't running

/api/cron/reset-quotas

Schedule: daily at midnight UTC
What it does: finds organization_quota rows where resetAt < (now - 28 days). Resets scansThisMonth to 0 and rolls resetAt forward to now().

If this stops:

  • Symptom: orgs that should reset to 0 scans this month stay locked at last month's count
  • Impact: legitimate Pro/Team orgs hit "quota exceeded" errors on their first scan of the month
  • Diagnosis: query SELECT organizationId, resetAt FROM organization_quota WHERE resetAt < datetime('now', '-30 days') — if rows exist, cron isn't running

The 28-day floor handles months of varying length without calendar math (Jan 31 → Feb 28 → Mar 28 → ...). Slightly generous on 30/31-day months, which is the customer-friendly direction.

/api/cron/nightly-topic-insights-submit

Schedule: daily at 04:00 UTC
What it does: for each Pro+ org with scheduled-scan activity in the last 24 hours, submits a per-topic narrative batch to Anthropic's Batches API. The batch returns within ~24 hours; process-completed-batches picks it up.

If this stops:

  • Symptom: topic-level "/insights/topics/[id]" pages show stale narratives
  • Impact: deeper insights don't refresh; workspace-level insights unaffected
  • Diagnosis: check Inngest function run history for submitTopicInsightBatch

/api/cron/weekly-digest-submit

Schedule: Mondays at 13:00 UTC
What it does: submits a workspace-level digest narrative batch for every Pro+ org. The narrative powers the weekly-digest email + webhook delivery.

If this stops:

  • Symptom: Monday morning weekly digests don't arrive
  • Impact: lose a primary retention surface; customers don't get reminded of value weekly
  • Diagnosis: Inngest function history + Resend email delivery logs

/api/cron/prune-analytics-snapshots

Schedule: daily at 02:30 UTC
What it does: value-preserving pruning of the analytics_snapshot table. Deletes rows that are simultaneously:

  • Older than 90 days
  • Have NULL narrative (no LLM tokens were spent on them)
  • Not the latest row for their (organizationId, scope, subjectId, windowDays) key

Keeps everything with narrative value forever, plus the last 90 days of cache rows, plus the active row per scope.

If this stops:

  • Symptom: analytics_snapshot table grows unboundedly
  • Impact: query latency on /insights slowly degrades; not user-visible until table hits ~10k rows for a single scope
  • Diagnosis: SELECT COUNT(*) FROM analytics_snapshot — should plateau, not grow linearly

Adding a new cron

  1. Create the endpoint at apps/saas/app/api/cron/[name]/route.ts (where [name] is the new cron's slug)
  2. Use checkCronAuth(headers().get("authorization")) at the top — copy the pattern from any existing cron
  3. Add the entry to apps/saas/vercel.json under crons
  4. Deploy — Vercel auto-detects the new cron and starts the schedule

Tips:

  • Keep the handler fast (under 1 second ideally). Enqueue Inngest events for actual work
  • Return a JSON response with counters ({ processed: N, failed: M }) so log scraping is easy
  • Use dynamic = "force-dynamic" to avoid build-time caching of the handler

Monitoring cron health

In production:

  • Vercel dashboard → Crons — shows last invocation, status code, and a sparkline of recent runs
  • Sentry — set up alerts on /api/cron/* route errors via Sentry's Performance/Issues
  • Inngest Cloud → Functions — for the workers that crons dispatch to; shows retries, dead-letter queue depth

A weekly ops review should glance at all 11 crons' recent runs in the Vercel cron panel—anything red or missing a run gets investigated.

See also

On this page