TypeScript SDK v4 is now available! See what's new
Event Coordination

Building flows for lost customers

Coordinate between events to build AI-driven re-engagement, human-in-the-loop approvals, and multi-step user journeys.

It's common to build functions that react to user journeys, allowing you to coordinate multiple signals within your code. Some examples:

  • In e-commerce, when a user adds a product to their cart without checking out in 24 hours, send them a reminder email
  • In a SaaS app, if a user signs up but doesn't complete onboarding in 3 days, send them a nudge
  • When a lead enters your sales pipeline, remind the sales team if there's no outreach in a week
  • In an AI workflow, kick off a generation job and wait for a human to approve the output before publishing

In each case, you want to start a function from one event, then wait for another event within a time window. If the second event arrives, you continue one way. If it doesn't, you continue another. This lets you model complex user journeys and human-in-the-loop workflows declaratively, without managing timers, state machines, or polling loops.

§How this works

Inngest lets you build functions that coordinate between events using step.waitForEvent():

typescript
01import { inngest } from "./client";
02
03export default inngest.createFunction(
04 {
05 id: "cart-abandonment-flow",
06 triggers: [{ event: "cart/product.added" }],
07 // Cancel this instance whenever the user adds another product.
08 // A new instance starts, resetting the 24-hour window.
09 cancelOn: {
10 event: "cart/product.added",
11 timeout: "24h",
12 match: "data.cart_id",
13 },
14 },
15 async ({ event, step }) => {
16 // Pause and wait up to 24 hours for a purchase event
17 // from the same cart.
18 const purchased = await step.waitForEvent("wait-for-purchase", {
19 event: "cart/purchased",
20 timeout: "24h",
21 match: "data.cart_id",
22 });
23
24 // If the user purchased, we're done.
25 if (purchased !== null) {
26 return;
27 }
28
29 // No purchase after 24 hours. Send a reminder.
30 await step.run("send-reminder", () => {
31 sendCartReminderEmail({
32 email: event.user.email,
33 cart: event.data.cart_id,
34 });
35 });
36 }
37);

step.waitForEvent() pauses the function until a matching event arrives or the timeout expires. Inngest resumes your function with the received event data, or null if the timeout was reached. Your code continues from where it left off.

Cancellations: running the function once

We only want one active reminder flow per cart. If a user adds 3 products, they should receive a single reminder email, not three. The cancelOn option handles this: whenever another product is added to the same cart, older runs of this function are cancelled automatically. The last event resets the 24-hour window.

Human-in-the-loop AI workflows

The same coordination primitive works for AI approval flows. Start a generation, wait for a human to review it, and continue based on their decision:

typescript
01export const generateAndPublish = inngest.createFunction(
02 { id: "generate-and-publish", triggers: [{ event: "content/generation.requested" }] },
03 async ({ event, step }) => {
04 // Generate content with an LLM
05 const draft = await step.run("generate-draft", async () => {
06 return await llm.generate({
07 prompt: event.data.prompt,
08 model: "claude-sonnet-4-20250514",
09 });
10 });
11
12 // Notify the reviewer
13 await step.run("request-review", async () => {
14 await notifyReviewer({
15 contentId: event.data.contentId,
16 draft: draft.text,
17 reviewUrl: `${baseUrl}/review/${event.data.contentId}`,
18 });
19 });
20
21 // Wait up to 48 hours for a review decision
22 const review = await step.waitForEvent("wait-for-review", {
23 event: "content/review.completed",
24 timeout: "48h",
25 match: "data.contentId",
26 });
27
28 // No decision within 48 hours: leave the draft unpublished.
29 if (review === null) {
30 return;
31 }
32
33 if (review.data.approved) {
34 await step.run("publish", async () => {
35 await cms.publish(event.data.contentId, draft.text);
36 });
37 } else {
38 await step.run("archive-rejected", async () => {
39 await cms.archive(event.data.contentId, {
40 reason: review.data.feedback,
41 });
42 });
43 }
44 }
45);

The function generates content, sends it for review, and pauses. When the reviewer clicks "approve" or "reject" in your app, your app sends the corresponding event. Inngest resumes the function and continues with the publish or archive step. No polling, no state table, no cron job checking for pending reviews.

§Alternative approaches

You can handle these flows within standard applications by:

  • Creating scheduled functions that run every hour to check for abandoned carts within the 23-24 hour window
  • Enqueuing a function to wait 24 hours, then checking the database directly for the current state

These approaches work but require more code, more distributed state to manage, and more things that can break during a refactor. Event coordination with step.waitForEvent() keeps all the logic in one place and makes the flow readable as a single function.

§Additional resources