Cron and Scheduling
GiftWrapt has a handful of scheduled jobs that handle reveal-after-event, outbound email, retention cleanup, and the background queues. They’re all HTTP endpoints under /api/cron/*, gated by a bearer token, and meant to be poked by an external scheduler.
The Endpoints
Section titled “The Endpoints”| Endpoint | Suggested schedule | What it does |
|---|---|---|
/api/cron/auto-archive | 0 6 * * * | Archives claimed items past the birthday / Christmas / holiday reveal date. This is what triggers the recipient-facing reveal. Skips lists with an active per-list extension and reveals any whose extension has elapsed. |
/api/cron/birthday-emails | 0 7 * * * | Day-of birthday greetings, post-birthday gifter summaries, pre-event reminders, relationship reminders, and the orphan-claim cleanup passes. |
/api/cron/cleanup-verification | 0 3 * * * | Deletes expired better-auth verification rows; sweeps cron-run retention rows. |
/api/cron/intelligence-recommendations | 0 4 * * * | Runs the per-user analyzer pipeline; persists recommendations + run rows. |
/api/cron/item-scrape-queue | 0 5 * * * | Drains pending item_scrape_jobs rows from bulk-import. |
All five are daily in the default schedule, deliberately staggered so they don’t pile up at the same minute.
The Cron Secret
Section titled “The Cron Secret”All /api/cron/* endpoints check an Authorization: Bearer <secret> header against the CRON_SECRET env var. If the env var is unset, the handlers fail closed with HTTP 503 - they don’t accidentally run publicly.
- Self-host: required. The bundled cron sidecar fatals at startup if
CRON_SECRETis unset, so the stack boot-loops until you set this. - Render:
render.yamlgenerates the value for you and attaches it to both the web service and the cron jobs. - Vercel:
vercel.jsondeclares the schedules but not the secret. SetCRON_SECRETyourself in the project’s environment variables. - Railway: set it yourself.
railway.jsoncarries only build and healthcheck config, and Railway’s cron is wired up through the dashboard.
Generate one with:
openssl rand -base64 48 | tr -d '/+=' | head -c 48Skip States and Idempotency
Section titled “Skip States and Idempotency”Cron handlers return JSON describing what they did. Every run is also persisted as a row in cron_runs (start, finish, status, duration, structured summary jsonb, plus error / skipReason) so operators can answer “did the scheduler actually fire today?” without SSHing into a host or wiring an external observability stack.
Common skip states:
skipReason | When |
|---|---|
disabled | Feature flag off in app_settings (e.g. Intelligence disabled). |
no-provider | AI / email / storage config missing. |
not-due | Date math says today isn’t the right day (e.g. auto-archive for a list whose birthday is in 200 days). |
lock-held | Intelligence: another run is in flight for this user. |
cooldown | Intelligence, manual refresh only: the user hit intelligenceManualRefreshCooldownMinutes. |
Intelligence also skips work below the run level: an analyzer whose candidate slice hashes the same as the last real generation returns before its model call and carries its existing recs forward. That’s a per-analyzer economy, not a run skip, so the run still records as a success. See Intelligence.
Deployment Shapes
Section titled “Deployment Shapes”Vercel
Section titled “Vercel”Schedules live in vercel.json and run via Vercel Cron. The CRON_SECRET is set automatically by the deploy config. Don’t override anything unless you’re moving the schedule.
Render
Section titled “Render”render.yaml defines the schedules and provisions CRON_SECRET as a generated secret. The cron jobs run as separate Render Cron services hitting the deployed web service URL.
Railway
Section titled “Railway”railway.json carries build and healthcheck config only - no schedules. Either wire up Railway’s cron through the dashboard (one cron service per endpoint), or run an external scheduler.
Self-Host (Docker Compose)
Section titled “Self-Host (Docker Compose)”The bundled compose files include a cron sidecar - a tiny container that runs system crond and curls the endpoints on the schedules above. It reads CRON_SECRET from the same .env as the app and fatals at boot if the secret is unset.
You can disable the sidecar and run your own scheduler if you prefer; just hit the URLs with the bearer header.
External Scheduler
Section titled “External Scheduler”Any scheduler that can fire an HTTP request with a bearer header works:
curl -fsSL -H "Authorization: Bearer $CRON_SECRET" \ https://giftwrapt.example.com/api/cron/auto-archiveCron-job.org, GitHub Actions schedules, EasyCron, AWS EventBridge, plain crontab - all fine.
Admin > Scheduling
Section titled “Admin > Scheduling”The admin scheduling page shows every endpoint, its documented schedule, the next-fire time computed from the cron expression, and the recent history pulled from cron_runs. Use it to:
- Confirm cron is actually firing.
- Inspect the structured
summaryjsonb for each run. - Look at failures and their
errorpayloads. - See how long each run took.
The page doesn’t control the scheduler - it just reads what the scheduler-of-record (Vercel, your sidecar, etc.) actually did. If a job hasn’t fired and the admin page agrees, the issue is in the scheduler, not GiftWrapt.
Orphan-Claim Cleanup
Section titled “Orphan-Claim Cleanup”The /api/cron/birthday-emails tick also runs two passes for the orphaned-claim flow:
- Reminder pass - day-before email to giftgivers (and partners) with un-acked orphans whose parent list’s event date is exactly one day from today. For wishlists - which have no event date - the reminder fires 13 days after the recipient deleted the item. Idempotent via
giftedItems.orphanReminderSentAt. Email-gated like the rest ofbirthday-emails. - Cleanup pass - hard-deletes every claim on the pending-deletion item plus the item row itself, on the parent list’s event date (or 14 days after deletion for wishlists). Runs regardless of email configuration - this is a data lifecycle operation, not a notification.
Both passes operate on pending-deletion items regardless of lists.isActive, so an archived parent list doesn’t strand orphans in limbo.
Retention
Section titled “Retention”cron_runs rows are pruned by the daily verification-cleanup tick. The retention window is cronRunsRetentionDays (default 90), tunable in admin settings. Set to 0 to disable retention sweeps (rows accumulate forever).