GenerateSaaS

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.ts builds the functions array 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].

JobGroupTriggerRegistration gate
send-emailnotificationsevent email/sendalways registered
webhook-cleanupmaintenancecron (daily)always registered
audit-log-cleanupmaintenancecron (weekly)always registered
license-heartbeatmaintenancecron (daily, per-install time)always registered (intentional)
user-onboardinglifecycleevent user/onboarding.completedalways registered
inactive-user-checklifecyclecron (daily, 10:00 UTC)config.email.inactiveEmailSchedule non-empty
we-miss-youlifecycleevent user/inactiveconfig.email.inactiveEmailSchedule non-empty
plan-expiry-checkbillingcron (daily, midnight UTC)config.payment.enabled
recurring-creditsbillingcron 0 0 * * * (daily)pricingConfig.credits.enabled
newsletter-syncnotificationscron (daily)config.newsletter.enabled
notification-cleanupnotificationscron (daily)config.notifications.enabled
broadcast-emailnotificationsevent announcement/broadcast.emailconfig.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

Groups

  • 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. See Credits.
  • lifecycle: onboarding and re-engagement email; user-onboarding spreads sends over two weeks with step.sleep and cancels on user/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.

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 /inngest behind its owning flag - the single place gates are applied.

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

On this page