Skip to content

Sending events from .NET to Inngest

Events start functions. An event has a name and JSON data, and every function with a matching trigger gets a run. There are two ways to send one: IInngestClient from ordinary application code, and context.Step.SendEventAsync from inside a function.

An event’s data is usually a record. Implement IInngestEventData on it to give the record the event’s name, and code can send the record itself:

Events.cs
using Inngest;
public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total) : IInngestEventData
{
public static string EventName => "shop/order.placed";
}
public sealed record LabelRequested(string OrderId) : IInngestEventData
{
public static string EventName => "shipping/label.requested";
// Optional: the version of the event's schema, sent as "v".
public static string? EventVersion => "2026-10-01";
}

The functions that run on the event name the type as well, with Trigger.Event<OrderPlaced>() or [EventTrigger<OrderPlaced>]. The name can then no longer drift from the data it carries, and a typo is a compile error. Everything below also works with an InngestEvent that names the event as a string, for events without a record of their own.

AddInngest registers IInngestClient as a singleton. Inject it anywhere:

CheckoutService.cs
using Inngest;
public sealed record Order(string Id, string CustomerId, decimal Total);
public sealed class CheckoutService(IInngestClient inngest, ILogger<CheckoutService> logger)
{
public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)
{
// Save the order first, then announce it. If the app dying in between must not lose the event, use the
// transactional outbox, which saves the event with the order.
var sent = await inngest.SendAsync(new OrderPlaced(order.Id, order.CustomerId, order.Total), cancellationToken);
logger.LogInformation("Order {OrderId} placed as event {EventId}", order.Id, sent.Ids[0]);
}
}

SendAsync returns the ids Inngest gave the events, in the order they were sent. To send several events in one request, pass a list:

await inngest.SendAsync(orders.Select(order => new OrderPlaced(order.Id, order.CustomerId, order.Total)), cancellationToken);

To set an id, a timestamp or user data, wrap the record in an InngestEvent, as the sections below do. Take its name from the type:

await inngest.SendAsync(new InngestEvent(OrderPlaced.EventName, new OrderPlaced(order.Id, order.CustomerId, order.Total))
{
Id = $"order-placed-{order.Id}",
}, cancellationToken);

Inside a function, send events with the step tool. The send is a step, so it happens once per run: a replay returns the saved ids instead of sending the events again.

var order = context.Event.Data;
await context.Step.SendEventAsync("request-shipping", new LabelRequested(order.OrderId));

The step takes a list of records too, or InngestEvents for events named by a string.

Sending through IInngestClient inside a function would send again on every call that passes that line. Use the step tool there.

Events sent from a run carry the run’s sessions, and with Inngest.UserContext the user who caused it.

InngestEvent is a record with the name and the data, plus optional properties. A typed send creates one with the type’s name and version:

Property Meaning
Name The event name, such as shop/order.placed. Names that start with inngest/ are reserved and rejected.
Data Any object; serialized with the app’s serializer options, so property names are camel case by default.
Id A deduplication key. Inngest ignores a second event with the same id for 24 hours.
Timestamp When the event happened. Defaults to when Inngest receives it. A time in the future delays the runs it starts.
User Optional user data.
Version An optional version of the event’s schema, sent as v. A typed event takes it from EventVersion.
Meta The sessions the event belongs to.

Set Id to something that identifies the occurrence, not the attempt to send it: an order id, a webhook delivery id, a request id. If the same thing is sent twice, by a retry or a second server, Inngest keeps the first and starts no new runs for the second. Ids are global across event names, so include the name or a prefix.

The transactional outbox relies on this to send an event again safely.

The Inngest docs explain event ids and deduplication.

Webhooks from Stripe, GitHub, Shopify and others arrive as events whose id is the provider’s id for the delivery, so a delivery that is sent again starts no second run.

A future Timestamp makes the functions an event triggers start at that time:

await inngest.SendAsync(new InngestEvent("trial/ending-soon", new { userId })
{
Timestamp = DateTimeOffset.UtcNow.AddDays(12),
}, cancellationToken);

Inside a function, sleep instead; see Sleep and wait for events.

Sessions group runs by the work they belong to, such as a support ticket or a conversation, so the Inngest UI can show them together. An event names its sessions as key and id pairs, up to five:

await inngest.SendAsync(new InngestEvent("support/ticket.opened", new { TicketId = "ticket_42" })
{
Meta = new EventMeta
{
Sessions = new Dictionary<string, string?> { ["ticket_id"] = "ticket_42", ["user_id"] = "user_7" },
},
});

A run’s sessions are those every triggering event belongs to, at most five, in context.Sessions. Events the run sends, from a send step or through IInngestClient, and functions it invokes inherit them, so the whole chain of work stays in the session. An event’s own sessions win over inherited ones, and a null id drops an inherited session; InheritSessions = false sends an event without any. InvokeOptions.Meta does the same for an invocation:

await context.Step.InvokeAsync<string>("summarize", new InvokeOptions("summarize-conversation")
{
Meta = new EventMeta { Sessions = new Dictionary<string, string?> { ["user_id"] = null } },
});

InngestOptions.SessionPropagation = false, or INNGEST_SESSION_PROPAGATION=false, turns inheriting off for the app; context.Sessions is then empty. A received event’s sessions are in Meta.Sessions, also on the event a wait matched. Waits do not match on sessions. Middleware sees the inherited sessions in Meta.PropagatedSessions and may remove some before an event is sent.

In production the client needs an event key: set INNGEST_EVENT_KEY or InngestOptions.EventKey. Without one, SendAsync throws an InngestException. In dev mode no key is needed; the dev server accepts any. See Configuration.

The client retries a send that fails with a network error, a timeout, a 5xx, 408, 425 or 429, up to five attempts, waiting 100 ms, 200 ms, 400 ms and so on with some jitter in between. Other errors, such as 401 for a wrong key or 400 for an invalid event, fail at once.

Every attempt of one SendAsync call carries the same seed for event ids, so a retry after a lost response creates the same events rather than copies.

When the send fails for good, SendAsync throws InngestException, with the HTTP status in StatusCode when there was one. Inside a function, SendEventAsync fails like any other step and is retried with it.

try
{
await inngest.SendAsync(new InngestEvent("shop/cart.abandoned", new { cartId = "c_42" }), cancellationToken);
}
catch (InngestException exception) when (exception.StatusCode is 401)
{
logger.LogError(exception, "The Inngest event key is wrong or missing.");
throw;
}

Middleware can see and change every event the app sends, from code and from functions, for example to add tracing data or a tenant id. See WrapSendEventAsync in Middleware.

Inngest limits the size of an event and the number of events per request; the limits page lists them.

Nightly billing fans out with SendEventAsync, and the webhook pipeline sends one event per delivery.

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