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():
01import { inngest } from "./client";0203export 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 event17 // 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 });2324 // If the user purchased, we're done.25 if (purchased !== null) {26 return;27 }2829 // 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:
01export const generateAndPublish = inngest.createFunction(02 { id: "generate-and-publish", triggers: [{ event: "content/generation.requested" }] },03 async ({ event, step }) => {04 // Generate content with an LLM05 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 });1112 // Notify the reviewer13 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 });2021 // Wait up to 48 hours for a review decision22 const review = await step.waitForEvent("wait-for-review", {23 event: "content/review.completed",24 timeout: "48h",25 match: "data.contentId",26 });2728 // No decision within 48 hours: leave the draft unpublished.29 if (review === null) {30 return;31 }3233 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.