Background Jobs
Run durable, scheduled, and event-driven work with Inngest functions in @repo/api, each registered behind its own config flag.
Background work runs on Inngest - durable functions with automatic retries, step memoization, sleeps, and cron schedules. The client lives in @repo/runtime; the functions live under packages/api/src/functions/ and are served on one /inngest endpoint by packages/api/src/routes/inngest.ts, each registered only when its @repo/config flag is on.
Functions only run when the Inngest dev server is up. Start it with pnpm dev:inngest, set INNGEST_BASE_URL=http://127.0.0.1:8288, and use the dev UI at that same URL to inspect runs, replay events, and trigger crons. In production, Inngest Cloud discovers /inngest instead.
Job catalog
Five jobs are ungated and always register. Everything else needs its gate satisfied; when a flag is off the job is never registered, so its events and crons become inert no-ops.
| Job | Group | Trigger | Registration gate |
|---|---|---|---|
| send-email | notifications | event email/send | always registered |
| webhook-cleanup | maintenance | cron 0 2 * * * | always registered |
| audit-log-cleanup | maintenance | cron 0 3 * * 0 (weekly) | always registered |
| license-heartbeat | maintenance | cron (daily, per-install time) | always registered (intentional) |
| user-onboarding | lifecycle | event user/onboarding.completed | always registered |
| inactive-user-check | lifecycle | cron 0 10 * * * | config.email.inactiveEmailSchedule non-empty |
| we-miss-you | lifecycle | event user/inactive | config.email.inactiveEmailSchedule non-empty |
| plan-expiry-check | billing | cron 0 0 * * * | config.payment.enabled |
| recurring-credits | billing | cron 0 0 * * * | pricingConfig.credits.enabled |
| newsletter-sync | notifications | cron 0 3 * * * | config.newsletter.enabled |
| notification-cleanup | notifications | cron 0 4 * * * | config.notifications.enabled |
| broadcast-email | notifications | event announcement/broadcast.email | config.notifications.enabled |
| broadcast-email-send | notifications | event announcement/broadcast.email.user | config.notifications.enabled |
| send-sms | notifications | event sms/send | config.sms.enabled |
| broadcast-sms | notifications | event announcement/broadcast.sms | config.notifications.enabled && config.sms.enabled |
| admin-notification | notifications | event admin-notification/send | config.adminNotifications.enabled |
| ai-user-automations | ai | cron */5 * * * * | config.ai.enabled |
| email-tracking-cleanup | maintenance | cron 0 4 * * * | config.email.tracking.enabled |
| content-views-ranking | content | cron 20 * * * * (hourly) + event content/views.ranking.requested | config.content.defaults.viewTracking.enabled && a section with template article |
- billing:
recurring-creditsgrants each plan's bundled credits on itscreditInterval, guarded against double-grants perYYYY-MM-DDviaclaimWebhookEvent;plan-expiry-checkreverts lapsed subscribers topricingConfig.defaultPlan, writing each one aPLAN_CHANGEledger row and sending the plan-removed notification. See Credits. - lifecycle: onboarding and re-engagement email;
user-onboardingspreads sends over two weeks withstep.sleepand cancels onuser/deleted. - notifications: decouples delivery from the request; see Notifications.
- content:
content-views-rankingfolds the per-day view hashes into one precomputed ranking key per article section, so listing pages order by popularity with a single cache read instead of aggregating in a request path; its event trigger carries a{ section }payload and is fired by a listing page that found no ranking to read, and the all-time window covers documents viewed in the last 91 days - the day hashes' retention horizon. See Caching.
The license-heartbeat is by design - it verifies your license (no user data) and no-ops without a licenseToken. The supported opt-out is the eject command; do not patch the function out by hand.
Defining a function
A function pairs a trigger with handler steps; each step.run is checkpointed and retried independently.
export const recurringCreditsFunction = inngest.createFunction(
{ id: "recurring-credits", retries: 2, triggers: { cron: "0 0 * * *" } },
async ({ step }) => {
const due = await step.run("find-eligible-entities", () =>
findActivePlanEntities(scope, now),
);
for (const entity of due) {
await step.run(`grant-credits-${entity.id}`, () => grant(entity));
}
},
);Registering a function
Add each function to the functions array in routes/inngest.ts behind its owning flag - the single place gates are applied.
const functions = [
sendEmailFunction,
webhookCleanupFunction,
auditLogCleanupFunction,
licenseHeartbeatFunction, // always registered
userOnboardingFunction,
...(hasPayment ? [planExpiryCheckFunction] : []),
...(hasCredits ? [recurringCreditsFunction] : []),
// ...lifecycle + notification functions, each flag-gated
];
const app = new Hono().on(["GET", "PUT", "POST"], "/inngest", serve({ client: inngest, functions }));Frequently asked questions
Do I need a paid Inngest account to develop? No. The dev server runs everything locally with full retries and a UI; Inngest Cloud is only needed for production execution.
Why does license-heartbeat run even with billing off?
It is unrelated to feature flags - a daily license check. Use the eject command to remove it rather than editing the function.
How do I add a new scheduled job?
Define the function with a cron trigger under packages/api/src/functions/, then add it to the functions array in routes/inngest.ts behind its owning config flag.
Schema changes
Generate, review, and apply Drizzle migrations - the scripts, the db:deploy step every deploy runs, and the checks to make before narrowing a money column.
Caching & Rate Limiting
Cache, rate-limit, and coordinate work across instances with Redis through @repo/runtime and the cacheConfig table.