Skip to content

Functions, event triggers and cron in C#

A function is code that Inngest runs for you, plus options that tell Inngest when and how to run it. The code is an ordinary .NET method. The options are FunctionOptions, which you write in code when you register the function, or as attributes on its class. This page covers the code side and the triggers. Flow control, retries and the other options have pages of their own.

Derive from InngestFunction<TEventData>, where TEventData is the type of the triggering event’s data:

ChargeOrder.cs
using Inngest;
public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total) : IInngestEventData
{
public static string EventName => "shop/order.placed";
}
public sealed class ChargeOrder(IOrderStore orders, IPaymentGateway payments) : InngestFunction<OrderPlaced>
{
public override async Task<object?> ExecuteAsync(FunctionContext<OrderPlaced> context, CancellationToken cancellationToken)
{
var order = context.Event.Data;
var paymentId = await context.Step.RunAsync("charge",
ct => payments.ChargeAsync(order.CustomerId, order.Total, ct));
await context.Step.RunAsync("mark-paid",
ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct));
return new { order.OrderId, paymentId };
}
}
public interface IOrderStore
{
Task MarkPaidAsync(string orderId, string paymentId, CancellationToken cancellationToken);
}
public interface IPaymentGateway
{
Task<string> ChargeAsync(string customerId, decimal amount, CancellationToken cancellationToken);
}

Put the options on the class as attributes and register it with AddFunction<TFunction>, or pass the options to AddFunction in code:

[InngestFunction("charge-order", Name = "Charge an order")]
[EventTrigger<OrderPlaced>]
Program.cs
builder.Services
.AddInngest(options => options.AppId = "shop")
.AddFunction<ChargeOrder>();

[InngestFunction] on the class gives the function its id, and optionally a name, retries and an idempotency key. The other attributes add triggers and flow control. AddFunction reads them once, when the app starts. With many classes, the source generator can write AddInngestFunctions(), which registers every class that has [InngestFunction], once the project turns it on.

The SDK resolves a new instance from a dependency injection scope for every call Inngest makes, and disposes the scope when the call ends. Scoped services such as an Entity Framework DbContext work as they do in a controller. The scope is the call’s, not a step’s; Scoped steps gives a step a scope of its own. You do not need to register the class yourself; AddFunction adds it as a transient service if it is not there.

ExecuteAsync returns the function’s output, which Inngest stores and shows with the run. Return null when there is nothing to return. The output is serialized to JSON, like everything a function hands to Inngest. To give the output a type, derive from InngestFunction<TEventData, TResult> instead; a function that invokes this one then receives a TResult.

Both forms say the same things. Each attribute sets the FunctionOptions member of the same name, and function options maps one to the other. As attributes, the options sit on the class, next to the code they describe, and take durations as time strings such as "30s". In code, they sit with the registration, can use values the app reads at startup and take durations as TimeSpans. These docs show attributes first; choosing Code first in one of the tabs switches the tabs on every page to that form.

A function’s options come from one place: options passed in code replace the class’s attributes. To start from the attributes and change some of the options in code, for example a limit read from configuration, pass a callback:

var limit = builder.Configuration.GetValue("Billing:ChargeConcurrency", 10);
inngest.AddFunction<ChargeOrder>(options => options with { Concurrency = [new ConcurrencyLimit(limit)] });

The callback is also where function middleware and checkpointing go for a class with attributes, since they have no attributes.

Every attribute but [InngestFunction] derives from FunctionOptionsAttribute, and an attribute of your own can too. It overrides Configure and returns the options with what it sets, which packages options that several functions share, such as the limits of an API they all call:

PaymentProviderAttribute.cs
using Inngest;
// One charge per customer at a time, and more retries, for every function that calls the payment provider.
public sealed class PaymentProviderAttribute : FunctionOptionsAttribute
{
protected override FunctionOptions Configure(FunctionOptions options) =>
options with
{
Retries = 10,
Concurrency = [.. options.Concurrency ?? [], new ConcurrencyLimit(1) { Key = "event.data.customerId" }],
};
}
[InngestFunction("charge-order", Name = "Charge an order")]
[EventTrigger<OrderPlaced>]
[PaymentProvider]

AddFunction starts from [InngestFunction] and applies the other attributes in ascending Order, which is 0 unless an attribute sets it, and those with the same Order in the order they are declared. Each one sees what the earlier ones set and replaces it where it sets the same member, so [PaymentProvider] replaces any Retries given on [InngestFunction].

OrderPlaced implements IInngestEventData, which puts the event’s name on the record that carries its data. Code then names the type instead of repeating the string: Trigger.Event<OrderPlaced>() and [EventTrigger<OrderPlaced>] trigger on it, SendAsync(new OrderPlaced(...)) sends it, and waits and cancellations can name it too. A mistyped name becomes a compile error, and nothing can send UserDeleted data under the name of UserSignedUp.

EventName and the optional EventVersion are static members, which the SDK reads without reflection, so typed events work under Native AOT. Every method that takes a type also has a form that takes the name as a string.

Small functions can be lambdas, with their options in code. Without a type argument the event data is a JsonElement:

inngest.AddFunction(new FunctionOptions { Id = "hello", Triggers = [Trigger.Event("demo/hello")] },
async (context, cancellationToken) =>
{
var name = context.Event.Data.GetProperty("name").GetString();
return await context.Step.RunAsync("greet", () => $"Hello, {name}!");
});

With a type argument it is deserialized as it is for a class:

inngest.AddFunction<OrderPlaced>(new FunctionOptions { Id = "log-order", Triggers = [Trigger.Event<OrderPlaced>()] },
async (context, cancellationToken) =>
{
context.Logger.LogInformation("Order {OrderId} for {Total}", context.Event.Data.OrderId, context.Event.Data.Total);
return null;
});

A delegate is created once, so it cannot take constructor dependencies. Resolve services from context.Services, which is the scope of the current call, or use a class.

FunctionContext<TEventData> carries everything about the current call:

Property What it holds
Event The triggering event, with Data deserialized into TEventData, plus Name, Id, Timestamp, User and Version.
Events Every event of a batch when the function uses BatchEvents; otherwise a list with Event only.
Step The step tools: RunAsync, SleepAsync, SleepUntilAsync, WaitForEventAsync, InvokeAsync and SendEventAsync.
RunId The id of the run. It stays the same across every call and retry of one run.
Attempt Zero on the first attempt of the current step or function, one more on every retry.
MaxAttempts The number of attempts Inngest allows, when it says so.
Logger An ILogger that stays quiet while memoized steps replay, so each message is written once per run.
Services The dependency injection scope of this call.
FunctionId The function’s id as registered, without the app id.
CancellationToken Canceled when Inngest abandons the call or the app shuts down. Functions also receive it as a parameter.

The SDK deserializes data with InngestOptions.SerializerOptions, which default to JsonSerializerDefaults.Web: property names are camel case and matched without regard to case. A missing or null data is read as an empty object. Use records with the properties you need; properties the event has and the record does not are ignored.

Use JsonElement when the shape varies or when you only need a field or two. To change how JSON is read, for example to add converters, change SerializerOptions when you call AddInngest. Native AOT and trimming shows how to register source-generated types.

[EventTrigger<T>] runs the function for every event of type T, and [EventTrigger("name")] for every event with that name; in code they are Trigger.Event<T>() and Trigger.Event("name"). The optional If is an expression that Inngest evaluates against the event before it starts a run:

[EventTrigger<OrderPlaced>(If = "event.data.total >= 500")]
[EventTrigger("shop/order.flagged")]

A function can have up to ten triggers and runs for any of them. Inngest refuses a sync with the same trigger twice, or the same cron schedule twice even with different jitter, so the SDK refuses both at startup. Event names are free text, but Inngest suggests a domain/noun.verb style in the past tense, such as billing/invoice.paid. Names that start with inngest/ belong to Inngest; the SDK refuses to send them. The event and trigger concepts page of the Inngest docs has more, including wildcard triggers.

[CronTrigger], or Trigger.Cron in code, runs the function on a schedule. Schedules have five fields and run in UTC unless you give a time zone:

[CronTrigger("0 6 * * 1-5", Timezone = "Europe/Prague")]
[CronTrigger("TZ=America/New_York 30 17 * * 5", Jitter = "2m")]

The SDK sends the time zone as a TZ= prefix, which is how Inngest reads it, so both forms above work the same way. The jitter of the second schedule starts each scheduled run up to two minutes later, so that functions on the same schedule do not all start at once; it can be from 1 second to 5 minutes.

A scheduled run has an event too, named inngest/scheduled.timer, so read its data as JsonElement. See schedules and delayed starts in the Inngest docs for how schedules behave around time zone changes.

A function registered with an empty trigger list, or a class with [InngestFunction] and no trigger attribute, never starts on its own. Other functions run it with InvokeAsync, by its class:

ScoreAccount.cs
using System.Text.Json;
using Inngest;
public sealed class ScoreAccount : InngestFunction<JsonElement, int>
{
public override Task<int> ExecuteAsync(FunctionContext<JsonElement> context, CancellationToken cancellationToken) =>
Task.FromResult(context.Event.Data.GetProperty("accountId").GetString()!.Length * 10);
}
[InngestFunction("score-account")]

The attribute goes on ScoreAccount, and inngest.AddFunction<ScoreAccount>() registers it.

The id is required and must be unique within the app. Inngest knows the function as <app id>-<id>, so changing the id, or the app id, makes a new function and leaves the old one without code. The name is only for display and defaults to the id.

The SDK checks every function’s options when the app starts, whether they come from code or attributes. If something is invalid, such as a duplicate id or an option out of range, it throws one InvalidOperationException that lists every problem it found, each naming its function. It does not wait for Inngest to reject the sync. Attribute durations are time strings, such as "30s", "5m" or "1h30m", because attribute arguments must be constants; one that is not a time string fails when AddFunction reads it.

To change a function while runs are in flight, keep its id and the ids of the steps those runs have already reached. Inngest’s versioning guide explains what is safe to change.

Nightly billing runs on a cron trigger, and fan-out with invoke has a function without triggers.

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