# Sending events from .NET to Inngest

> Send events to Inngest from application code with IInngestClient and from functions with SendEventAsync, with ids for deduplication, delayed starts and retries.

Web page: https://damikun.github.io/inngest-dotnet/guides/sending-events/

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.

## Typed events

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:

```csharp title="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.

## From application code

`AddInngest` registers `IInngestClient` as a singleton. Inject it anywhere:

```csharp title="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:

```csharp
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:

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

## From a function

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.

```csharp
var order = context.Event.Data;

await context.Step.SendEventAsync("request-shipping", new LabelRequested(order.OrderId));
```

The step takes a list of records too, or `InngestEvent`s 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`](https://damikun.github.io/inngest-dotnet/guides/user-context.md)
the user who caused it.

## What an event holds

`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](https://damikun.github.io/inngest-dotnet/guides/sending-events.md#sessions) the event belongs to. |

### Deduplicating with an id

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](https://damikun.github.io/inngest-dotnet/guides/outbox.md) relies on this to send an event again safely.

The Inngest docs explain [event ids and deduplication](https://www.inngest.com/docs/durable-execution/guides-and-advanced/events-and-triggers/send-events).

[Webhooks](https://damikun.github.io/inngest-dotnet/guides/webhooks.md) 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.

### Delaying runs

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

```csharp
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](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md).

## Sessions

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:

```csharp
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:

```csharp
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.

## Keys and modes

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](https://damikun.github.io/inngest-dotnet/guides/configuration.md).

## Failures and retries

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.

```csharp
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;
}
```

## Changing events in one place

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](https://damikun.github.io/inngest-dotnet/guides/middleware.md).

Inngest limits the size of an event and the number of events per request; the
[limits](https://www.inngest.com/docs/durable-execution/limits) page lists them.

## Related examples

[Nightly billing](https://damikun.github.io/inngest-dotnet/examples/nightly-billing.md) fans out with `SendEventAsync`, and the [webhook pipeline](https://damikun.github.io/inngest-dotnet/examples/webhook-pipeline.md) sends one event per delivery.
