Skip to content

User onboarding with waits and reminders

Onboarding mixes waiting and reacting: send a welcome email, wait for the user to activate, nudge them if they do not, and follow up a day after they do. The function sleeps and waits through all of it without holding anything.

OnboardUser.cs
using Inngest;
public sealed record UserSignedUp(string UserId, string Email, string Name) : IInngestEventData
{
public static string EventName => "user/signed-up";
}
public sealed record UserActivated(string UserId, string Plan) : IInngestEventData
{
public static string EventName => "user/activated";
}
public sealed record UserDeleted(string UserId) : IInngestEventData
{
public static string EventName => "user/deleted";
}
public sealed class OnboardUser(IMailer mailer, ICrm crm) : InngestFunction<UserSignedUp>
{
public override async Task<object?> ExecuteAsync(FunctionContext<UserSignedUp> context, CancellationToken cancellationToken)
{
var user = context.Event.Data;
await context.Step.RunAsync("send-welcome",
ct => mailer.SendAsync(user.Email, "welcome", new { user.Name }, ct));
var activated = await context.Step.WaitForEventAsync<UserActivated>("wait-for-activation",
TimeSpan.FromDays(3), match: "data.userId");
if (activated is null)
{
await context.Step.RunAsync("send-reminder",
ct => mailer.SendAsync(user.Email, "activation-reminder", new { user.Name }, ct));
activated = await context.Step.WaitForEventAsync<UserActivated>("wait-after-reminder",
TimeSpan.FromDays(4), match: "data.userId");
if (activated is null)
{
await context.Step.RunAsync("mark-dormant", ct => crm.MarkDormantAsync(user.UserId, ct));
return new { user.UserId, activated = false };
}
}
await context.Step.SleepAsync("wait-a-day", TimeSpan.FromDays(1));
var template = activated.Data.Plan == "team" ? "tips-for-teams" : "tips";
await context.Step.RunAsync("send-tips", ct => mailer.SendAsync(user.Email, template, new { user.Name }, ct));
return new { user.UserId, activated = true };
}
}
public interface IMailer
{
Task SendAsync(string to, string template, object model, CancellationToken cancellationToken);
}
public interface ICrm
{
Task MarkDormantAsync(string userId, CancellationToken cancellationToken);
}

Each event record names its event in EventName, so the trigger, the waits and the cancellation refer to the type. The function’s options give it its id, trigger, cancellation and rate limit, as attributes on the class or in code:

[InngestFunction("onboard-user")]
[EventTrigger<UserSignedUp>]
[CancelOn<UserDeleted>(Match = "data.userId")]
[RateLimit(1, "24h", Key = "event.data.userId")]

The attributes go on OnboardUser, and AddFunction reads them once at startup:

Program.cs (registration)
inngest.AddFunction<OnboardUser>();

When the user confirms their email, the app sends a UserActivated event, named user/activated, with the same userId:

await inngest.SendAsync(new UserActivated(userId, plan), cancellationToken);
  • Waiting for a person. WaitForEventAsync pauses the run for up to three days. match: "data.userId" makes only this user’s activation resume it. On timeout it returns null, and the function sends a reminder and waits again.
  • Branching on what happened. The activation event is a step result, so code after the wait sees the same event on every call, and the plan decides which tips to send.
  • Stopping early. The cancellation on UserDeleted ends the run when the account is deleted, even in the middle of a three-day wait.
  • One onboarding per user. The rate limit skips a second user/signed-up for the same user within a day, for example from a double-clicked sign-up button.

A wait only sees events that arrive after it starts. Here the welcome email goes out first, which takes a moment; see Sleep and wait for events if your users can activate faster than that.

inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.