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
| Endpoint | Schedule (UTC) | Purpose |
|---|---|---|
/api/cron/scheduled-scans | Every minute (* * * * *) | Dispatch due scheduled-scan runs to Inngest |
/api/cron/dispatch-scan-outbox | Every minute (* * * * *) | Retry durable scan handoffs to Inngest |
/api/cron/dispatch-notification-outbox | Every minute (* * * * *) | Retry durable email and webhook deliveries |
/api/cron/process-scan-batches | Every 15 min (*/15 * * * *) | Drain provider scan batches and finalize sessions |
/api/cron/process-completed-batches | Every 30 min (*/30 * * * *) | Process completed Anthropic Batch API jobs |
/api/cron/reset-quotas | Daily at 00:00 (0 0 * * *) | Reset monthly scan counters for orgs past 28-day window |
/api/cron/compose-notification-digest | Daily at 12:00 (0 12 * * *) | Fold parked alert emails into one daily digest per user |
/api/cron/nightly-topic-insights-submit | Daily at 04:00 (0 4 * * *) | Submit topic-level digest narrative batches |
/api/cron/weekly-digest-submit | Mondays at 13:00 (0 13 * * 1) | Submit weekly workspace digest narrative batches |
/api/cron/prune-analytics-snapshots | Daily at 02:30 (30 2 * * *) | Prune stale analytics_snapshot rows with no narrative value |
/api/cron/rollup-cost-ledger | Daily at 05:30 (30 5 * * *) | Materialize daily cost-ledger rollups |
/api/cron/reconcile-spend | Daily 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
processPendingBatchesruns 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_snapshottable grows unboundedly - Impact: query latency on
/insightsslowly 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
- Create the endpoint at
apps/saas/app/api/cron/[name]/route.ts(where[name]is the new cron's slug) - Use
checkCronAuth(headers().get("authorization"))at the top — copy the pattern from any existing cron - Add the entry to
apps/saas/vercel.jsonundercrons - 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
- Deploying BrandBanta — initial deploy that wires the cron auth
- Env vars —
CRON_SECRETsetup - Insights & narratives — what the digest crons feed