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.
/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.
  • Vercel / Render / Railway: the bundled platform configs (vercel.json, render.yaml) auto-generate or auto-attach the secret. You only need to set it yourself if you’re hitting the endpoints from your own scheduler.

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).
unchanged-inputIntelligence: inputs haven’t changed since last successful run AND we’re inside the refresh window.
lock-heldIntelligence: another run is in flight for this user.
unread-recs-existIntelligence: cron path only, the user has active recs from a prior batch.

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.

No railway.toml cron config. 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).