Background Jobs
Run durable, scheduled, and event-driven work with Inngest functions in @repo/api.
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 in @repo/api and are served on one /inngest endpoint, each registered only when its @repo/config flag is on.
How it works
- Functions live under
packages/api/src/functions/, grouped by domain. packages/api/src/routes/inngest.tsbuilds thefunctionsarray conditionally and serves them all at/inngest.- When a flag is off, the job is never registered - its events and crons become inert no-ops.
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
Every job ships only when its registration gate is satisfied; the five ungated jobs always run. Default config enables every flag, so all jobs register out of the box - inactiveEmailSchedule defaults to [7, 30, 90].
| Job | Group | Trigger | Registration gate |
|---|---|---|---|
| send-email | notifications | event email/send | always registered |
| webhook-cleanup | maintenance | cron (daily) | always registered |
| audit-log-cleanup | maintenance | cron (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 (daily, 10:00 UTC) | config.email.inactiveEmailSchedule non-empty |
| we-miss-you | lifecycle | event user/inactive | config.email.inactiveEmailSchedule non-empty |
| plan-expiry-check | billing | cron (daily, midnight UTC) | config.payment.enabled |
| recurring-credits | billing | cron 0 0 * * * (daily) | pricingConfig.credits.enabled |
| newsletter-sync | notifications | cron (daily) | config.newsletter.enabled |
| notification-cleanup | notifications | cron (daily) | config.notifications.enabled |
| broadcast-email | notifications | event announcement/broadcast.email | 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 |
Groups
- billing:
recurring-creditsgrants each plan's bundled credits on itscreditInterval, guarded against double-grants perYYYY-MM-DDviaclaimWebhookEvent;plan-expiry-checkreverts lapsed subscribers topricingConfig.defaultPlan. See Credits. - lifecycle: onboarding and re-engagement email;
user-onboardingspreads sends over two weeks withstep.sleepand cancels onuser/deleted. - maintenance: always-on cleanup and the daily
license-heartbeat. - notifications: decouples delivery from the request; flag-gated jobs log and return early when off. See Notifications.
The license-heartbeat is intentional and 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 /inngest behind its owning flag - the single place gates are applied.
const functions = [
sendEmailFunction,
webhookCleanupFunction,
auditLogCleanupFunction,
licenseHeartbeatFunction, // always registered
...(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; an Inngest Cloud account is only needed for production execution.
Why does license-heartbeat run even with billing off?
It is intentional and 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 /inngest behind its owning config flag.