Skip to content

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.

EndpointSuggested scheduleWhat it does
/api/cron/auto-archive0 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-emails0 7 * * *Day-of birthday greetings, post-birthday gifter summaries, pre-event reminders, relationship reminders, and the orphan-claim cleanup passes.
/api/cron/cleanup-verification0 3 * * *Deletes expired better-auth verification rows; sweeps cron-run retention rows.
/api/cron/intelligence-recommendations0 4 * * *Runs the per-user analyzer pipeline; persists recommendations + run rows.
/api/cron/item-scrape-queue0 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.

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_SECRET is unset, so the stack boot-loops until you set this.
  • Render: render.yaml generates the value for you and attaches it to both the web service and the cron jobs.
  • Vercel: vercel.json declares the schedules but not the secret. Set CRON_SECRET yourself in the project’s environment variables.
  • Railway: set it yourself. railway.json carries only build and healthcheck config, and Railway’s cron is wired up through the dashboard.

Generate one with:

Terminal window
openssl rand -base64 48 | tr -d '/+=' | head -c 48

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:

skipReasonWhen
disabledFeature flag off in app_settings (e.g. Intelligence disabled).
no-providerAI / email / storage config missing.
not-dueDate math says today isn’t the right day (e.g. auto-archive for a list whose birthday is in 200 days).
lock-heldIntelligence: another run is in flight for this user.
cooldownIntelligence, 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.

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.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.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.

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.

Any scheduler that can fire an HTTP request with a bearer header works:

Terminal window
curl -fsSL -H "Authorization: Bearer $CRON_SECRET" \
https://giftwrapt.example.com/api/cron/auto-archive

Cron-job.org, GitHub Actions schedules, EasyCron, AWS EventBridge, plain crontab - all fine.

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 summary jsonb for each run.
  • Look at failures and their error payloads.
  • 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.

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 of birthday-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.

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).