GenerateSaaS

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.

JobGroupTriggerRegistration gate
send-emailnotificationsevent email/sendalways registered
webhook-cleanupmaintenancecron 0 2 * * *always registered
audit-log-cleanupmaintenancecron 0 3 * * 0 (weekly)always registered
license-heartbeatmaintenancecron (daily, per-install time)always registered (intentional)
user-onboardinglifecycleevent user/onboarding.completedalways registered
inactive-user-checklifecyclecron 0 10 * * *config.email.inactiveEmailSchedule non-empty
we-miss-youlifecycleevent user/inactiveconfig.email.inactiveEmailSchedule non-empty
plan-expiry-checkbillingcron 0 0 * * *config.payment.enabled
recurring-creditsbillingcron 0 0 * * *pricingConfig.credits.enabled
newsletter-syncnotificationscron 0 3 * * *config.newsletter.enabled
notification-cleanupnotificationscron 0 4 * * *config.notifications.enabled
broadcast-emailnotificationsevent announcement/broadcast.emailconfig.notifications.enabled
broadcast-email-sendnotificationsevent announcement/broadcast.email.userconfig.notifications.enabled
send-smsnotificationsevent sms/sendconfig.sms.enabled
broadcast-smsnotificationsevent announcement/broadcast.smsconfig.notifications.enabled && config.sms.enabled
admin-notificationnotificationsevent admin-notification/sendconfig.adminNotifications.enabled
ai-user-automationsaicron */5 * * * *config.ai.enabled
email-tracking-cleanupmaintenancecron 0 4 * * *config.email.tracking.enabled
content-views-rankingcontentcron 20 * * * * (hourly) + event content/views.ranking.requestedconfig.content.defaults.viewTracking.enabled && a section with template article
  • billing: recurring-credits grants each plan's bundled credits on its creditInterval, guarded against double-grants per YYYY-MM-DD via claimWebhookEvent; plan-expiry-check reverts lapsed subscribers to pricingConfig.defaultPlan, writing each one a PLAN_CHANGE ledger row and sending the plan-removed notification. See Credits.
  • lifecycle: onboarding and re-engagement email; user-onboarding spreads sends over two weeks with step.sleep and cancels on user/deleted.
  • notifications: decouples delivery from the request; see Notifications.
  • content: content-views-ranking folds 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.

packages/api/src/functions/billing/recurring-credits.ts
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.

packages/api/src/routes/inngest.ts
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.

On this page