NextCRM

Background jobs

How NextCRM uses Inngest for events, scheduled jobs and long-running work.

Slow, external or scheduled work runs in Inngest functions. Request code sends an event and returns. Inngest calls the app back at /api/inngest to run the function, with retries.

Pieces

FileRole
inngest/client.tsThe Inngest client. Reads INNGEST_ID, INNGEST_APP_NAME, INNGEST_EVENT_KEY, INNGEST_SIGNING_KEY.
inngest/functions/One file per function, grouped by area.
inngest/lib/Shared helpers (embeddings, IMAP).
app/api/inngest/route.tsThe serve() endpoint. Every function must be listed here.
lib/plugins/inngest.tsBuilds functions for plugin crons, event handlers and after-actions.

Run it locally

pnpm inngest:up   # dev server on http://localhost:8288
pnpm dev

Set INNGEST_DEV=1 in .env.local so the SDK uses the dev server. The dev server syncs only http://host.docker.internal:3000/api/inngest. Open the dashboard to see registered functions, send test events and inspect runs. See Local setup.

Function catalogue

Event-triggered

Function idEventWhat it does
embed-account, embed-contact, embed-lead, embed-opportunitycrm/<entity>.savedRe-embeds the record if its text changed
embed-backfillcrm/backfill.requestedEmbeds existing records
enrich-contactenrich/contact.runEnriches one contact (Firecrawl + OpenAI)
enrich-contacts-bulkenrich/contacts.bulkFans out contact enrichment
enrich-targetenrich/target.runEnriches one target in an E2B sandbox
enrich-targets-bulkenrich/targets.bulkFans out target enrichment
enrich-target-contactenrich/target.contact.runEnriches one discovered target contact in E2B
email-sync-accountemail/sync-accountSyncs one IMAP account
email-embed-emailemail/embed-emailEmbeds one email
email-link-crmemail/link-crmLinks an email to CRM records
campaign-schedule-send, campaign-send-now, campaign-send-step, campaign-process-follow-upcampaigns/schedule, campaigns/send-now, campaigns/send-step, campaigns/follow-upCampaign sending
crm-qualified-cadence, crm-care-taskscrm/opportunity.stage-changedCreate follow-up tasks when an opportunity changes stage
crm-calendar-process-eventcrm/calendar.event.receivedProcesses an incoming calendar event
crm-google-calendar-sync-connectioncrm/calendar.google-syncSyncs one Google Calendar connection
crm-calendar-outbound-synccrm/calendar.outbound-syncPushes changes to the calendar
document-enrich, document-generate-thumbnaildocument/uploadedProcesses and previews uploaded documents
plugin-lifecycle-installplugin/installedRuns a plugin's onInstall

Scheduled

Function idCron (UTC)What it does
email-sync-all*/15 * * * *Queues sync for all email accounts
crm-google-calendar-sync-all*/15 * * * *Queues sync for all calendar connections
report-send-scheduled*/15 * * * *Sends scheduled reports that are due
crm-kill-rule0 6 * * *Closes stale opportunities
crm-recycle-targets30 6 * * *Recycles targets whose sequence ended
crm-renewal-reminders0 7 * * 1Creates renewal reminder tasks
ecb-sync-exchange-rates0 17 * * 1-5Fetches ECB exchange rates

Plugin functions are added at runtime with ids plugin-<id>-cron-<slug>, plugin-<id>-on-<slug> and plugin-<id>-after.

Add a function

Write the function

Create a file in inngest/functions/. The SDK is Inngest v4: triggers go in the options object.

// inngest/functions/crm/notify-big-deal.ts
import { inngest } from "@/inngest/client";
import { prismadb } from "@/lib/prisma";

export const notifyBigDeal = inngest.createFunction(
  { id: "crm-notify-big-deal", name: "Notify big deal", triggers: [{ event: "crm/opportunity.saved" }] },
  async ({ event, step }) => {
    const { record_id } = event.data as { record_id: string };

    const opp = await step.run("load", () =>
      prismadb.crm_Opportunities.findUnique({ where: { id: record_id, deletedAt: null } }),
    );
    if (!opp) return { skipped: "not found" };

    await step.run("notify", async () => {
      // ...
    });
    return { ok: true };
  },
);

Use step.run() around each side effect. Inngest retries a failed step without repeating the steps that already succeeded. Use a cron trigger (triggers: [{ cron: "0 6 * * *" }]) for scheduled work.

Register it

Import the function in app/api/inngest/route.ts and add it to the functions array. A function that is not listed there never runs.

Send the event

import { inngest } from "@/inngest/client";

void inngest.send({ name: "crm/opportunity.saved", data: { record_id: opportunity.id } });

Server actions usually send without awaiting (void) so the user does not wait. Await the send when the caller must know it was queued.

Conventions

  • Event names: <area>/<subject>.<verb>, for example crm/account.saved or enrich/target.run.
  • Payloads: keep them small. Send IDs (record_id), not records, and load fresh data in the function.
  • Function ids: stable and unique. Renaming an id creates a new function in Inngest.
  • Idempotency: functions can run more than once. The embedding functions, for example, compare a content hash and skip unchanged records.
  • Auth: functions run without a user session. If the work must respect a user's permissions, pass the user id in the event (the enrichment events use triggeredBy) and check it in the function.
  • The crm/<entity>.saved events: sent after creates and updates of accounts, contacts, leads and opportunities. Embeddings and plugin on handlers listen to them. Send the event from any new code path that writes these records.

Production

In production the app needs INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY from Inngest Cloud, or a self-hosted Inngest server with INNGEST_BASE_URL. The Docker Compose stack runs its own Inngest container. See the configuration reference.

On this page