# Inngest .NET SDK for durable functions and workflows

> inngest-dotnet is an Inngest SDK for .NET. Write durable functions and background workflows in C# with steps that retry, sleep and wait for events, served from ASP.NET Core, Azure Functions, AWS Lambda or a Connect worker.

Write Inngest functions as plain C# classes in your ASP.NET Core app, with realtime updates and the .NET ecosystem you already use. Inngest runs them; this SDK connects your .NET app to it.

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

## The .NET side of Inngest

Inngest is the platform that runs your functions: it receives events, keeps the state of every run, retries what fails and shows each run in its dashboard. This SDK is how a .NET app takes part. You write the functions in C#, and the SDK serves them to Inngest and sends your events. Inngest's own documentation covers the platform.

- [Inngest concepts](https://www.inngest.com/docs/durable-execution/concepts): Events, triggers, runs, steps and waits, the terms these guides use, explained by Inngest.
- [How Inngest functions run](https://www.inngest.com/docs/durable-execution/durable-workflows): How a run saves each step and resumes after a failure or a pause, the same for every SDK.
- [The Inngest dev server](https://www.inngest.com/docs/local-development): Run Inngest on your machine and watch your .NET functions run step by step in its UI.

[How it works](https://damikun.github.io/inngest-dotnet/how-it-works.md) shows what this SDK answers to each of Inngest's calls in a real run.

## Ordinary C#, durable by default

The function from the top of the page, in full. Each step is a call to RunAsync, SleepAsync or another step tool; the rest is plain C#.

```csharp title="CheckoutFunction.cs"
using Inngest;

public sealed record OrderPaid(string OrderId, string CustomerId, decimal Amount);

public sealed record Charge(string Id, decimal Amount);

public sealed class CheckoutFunction(IPayments pay, IMailer mail, IWarehouse warehouse)
    : InngestFunction<OrderPaid>
{
    public override async Task<object?> ExecuteAsync(
        FunctionContext<OrderPaid> context, CancellationToken cancellationToken)
    {
        var order = context.Event.Data;

        var charge = await context.Step.RunAsync("charge",
            ct => pay.ChargeAsync(order, ct));

        await context.Step.RunAsync("send-receipt",
            ct => mail.SendReceiptAsync(order, ct));

        await context.Step.SleepAsync("cooling-off",
            TimeSpan.FromMinutes(30));

        await context.Step.RunAsync("ship",
            ct => warehouse.ShipAsync(order, ct));

        return new { charge.Id };
    }
}

public interface IPayments
{
    Task<Charge> ChargeAsync(OrderPaid order, CancellationToken cancellationToken);
}

public interface IMailer
{
    Task SendReceiptAsync(OrderPaid order, CancellationToken cancellationToken);
}

public interface IWarehouse
{
    Task ShipAsync(OrderPaid order, CancellationToken cancellationToken);
}
```

**`RunAsync("charge", ...)`** is a step. Inngest stores the charge it returns, and later calls get that stored
result instead of charging again. If it throws, Inngest retries this step alone.

**`RunAsync("send-receipt", ...)`** is a step of its own. If the mail server fails, Inngest retries the receipt,
and the charge stays done.

**`SleepAsync("cooling-off", ...)`** ends the call. Inngest calls the function again in 30 minutes. Nothing runs
or waits in your app meanwhile, and deploys in between do not matter.

**`RunAsync("ship", ...)`** runs after the sleep. By then the charge and the receipt come back from Inngest's
store, not from the payment provider or the mail server.

**`return`** gives the run its output. The SDK answers `200` and Inngest marks the run completed.

## Everything the SDK can do

Steps and flow control, realtime and AI, hosting, integrations and testing. Each card shows the main call and links to its guide.

### Write workflows

#### Steps that run once

A step's result is saved, so a later call gets it back without running the code again.

```csharp
var paymentId = await context.Step.RunAsync(
    "charge", ct => payments.ChargeAsync(
        order.CustomerId, order.Total, ct));
```

[Steps guide](https://damikun.github.io/inngest-dotnet/guides/steps.md)

#### Sleep and wait for events

Pause for days, or until an event arrives, without holding a thread or a worker.

```csharp
var activated = await context.Step
    .WaitForEventAsync<UserActivated>(
        "activation", TimeSpan.FromDays(3));
```

[Sleep and wait guide](https://damikun.github.io/inngest-dotnet/guides/sleep-and-wait-for-events.md)

#### Parallel steps

Start steps together and await them with `Task.WhenAll`; Inngest runs each in its own call.

```csharp
await Task.WhenAll(userIds.Select(userId =>
    context.Step.RunAsync($"load-{userId}",
        ct => profiles.LoadAsync(userId, ct))));
```

[Parallel steps guide](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md)

#### Fan-out over a list

Run a step for each item of a list, with a limit on how many run at once and the results back in order.

```csharp
await context.Step.MapAsync("charge", due,
    (i, ct) => billing.ChargeAsync(i.Id, ct),
    new FanOutOptions { MaxParallelism = 5 });
```

[Fan-out guide](https://damikun.github.io/inngest-dotnet/guides/fan-out.md)

#### Invoke other functions

Run another function as a step and get its typed result back, with its own retries and history.

```csharp
var score = await context.Step
    .InvokeAsync<ScoreAccount, AccountScore>(
        "score-acme", new ScoreRequest("acme"));
```

[Invoking functions guide](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md)

#### Scoped steps

Run a step in a dependency injection scope of its own, so a `DbContext` carries no state from one step to the next.

```csharp
await context.Step
    .RunScopedAsync<IInventory>("reserve",
        (inv, ct) => inv.ReserveAsync(id, ct));
```

[Scoped steps guide](https://damikun.github.io/inngest-dotnet/guides/scoped-steps.md)

#### Event and cron triggers

Start a function from a typed event, a filtered event or a cron schedule, with its options as attributes or in code.

```csharp
[EventTrigger<OrderPlaced>(
    If = "event.data.total >= 500")]
[CronTrigger("0 6 * * 1-5", Timezone = "UTC")]
```

[Functions and triggers guide](https://damikun.github.io/inngest-dotnet/guides/functions-and-triggers.md)

#### Flow control

Concurrency, throttling, rate limits, debounce, batching, priority and singletons are attributes on the function.

```csharp
[Throttle(10, "1m")]
[Concurrency(1, Key = "event.data.accountId")]
[Debounce("30s", Key = "event.data.accountId")]
```

[Flow control guide](https://damikun.github.io/inngest-dotnet/guides/flow-control.md)

#### Errors and retries

Every step retries on its own. Exceptions stop the retries or ask for a delay.

```csharp
throw new RetryAfterException(
    "Rate limited.", TimeSpan.FromMinutes(1));
```

[Errors and retries guide](https://damikun.github.io/inngest-dotnet/guides/errors-and-retries.md)

### Realtime and AI

#### Realtime

Publish updates and streams of text from a run or from any code, and relay them to browsers or receive them in .NET.

```csharp
var status = new RealtimeTopic<string>(
    $"order:{orderId}", "status");
await realtime.PublishAsync(status, "Charged");
```

[Realtime guide](https://damikun.github.io/inngest-dotnet/guides/realtime.md)

#### AI chat clients

Run the model calls and tool calls of an `IChatClient` as steps, so a retry or a replay never repeats them.

```csharp
using Microsoft.Extensions.AI;
builder.UseInngestFunctionInvocation()
    .UseInngestSteps();
```

[AI chat clients guide](https://damikun.github.io/inngest-dotnet/guides/ai.md)

#### Blazor

Show realtime messages in Blazor Server, WebAssembly and interactive Auto with the `RealtimeView` component.

```csharp
services.AddInngest(
    options => options.AppId = "shop");
services.AddInngestRealtimeServerSource();
```

[Blazor guide](https://damikun.github.io/inngest-dotnet/guides/blazor.md)

#### SignalR

Forward realtime messages to the clients of a SignalR hub, and decide who may join which channel.

```csharp
services.AddInngestSignalR(options =>
    options.Authorize = authorize);
app.MapInngestRealtimeHub("/hubs/inngest");
```

[SignalR guide](https://damikun.github.io/inngest-dotnet/guides/signalr.md)

### Run it anywhere

#### Workers without an endpoint

With Connect, a worker holds an outbound WebSocket to Inngest and needs no public URL.

```csharp
inngest.AddConnect(connect =>
    connect.MaxWorkerConcurrency = 20);
```

[Connect guide](https://damikun.github.io/inngest-dotnet/guides/connect.md)

#### Durable endpoints Experimental

Run steps in an ASP.NET Core endpoint, answer the caller directly, and hand the run to Inngest when a step has to wait or fails. Inngest calls it beta, so the API may still change.

```csharp
app.UseInngestDurableEndpoints();
app.MapPost("/pay", (IStepTools step) =>
    step.RunAsync("charge", () => "paid"))
    .WithDurableEndpoint();
```

[Durable endpoints guide](https://damikun.github.io/inngest-dotnet/guides/durable-endpoints.md)

#### Azure Functions

Serve the endpoint from an HTTP-triggered function of an Azure Functions app in the isolated worker model.

```csharp
using Microsoft.Azure.Functions.Worker.Http;
// In an HTTP-triggered function
return await request.ServeInngestAsync();
```

[Azure Functions guide](https://damikun.github.io/inngest-dotnet/guides/azure-functions.md)

#### AWS Lambda

Serve the endpoint from a Lambda function behind API Gateway or a function URL, as a managed function or a Native AOT executable.

```csharp
var handler = new InngestLambdaHandler(sp);
return await handler.HandleAsync(
    request, context);
```

[AWS Lambda guide](https://damikun.github.io/inngest-dotnet/guides/aws-lambda.md)

#### Native AOT

Publish trimmed or as a Native AOT binary by giving the SDK source-generated JSON metadata for your types.

```csharp
using System.Text.Json.Serialization;
[JsonSerializable(typeof(OrderPlaced))]
partial class OrderJson : JsonSerializerContext;
```

[Native AOT guide](https://damikun.github.io/inngest-dotnet/guides/native-aot.md)

### Integrations

#### Send events

Send events from application code with `IInngestClient` or from a function as a step, with ids for deduplication and delayed starts.

```csharp
await inngest.SendAsync(
    new OrderPlaced("o-1001", "c-7", 49.90m));
```

[Sending events guide](https://damikun.github.io/inngest-dotnet/guides/sending-events.md)

#### Webhooks

Verify the signature of a webhook from Stripe, GitHub, Shopify, Clerk or Resend, and send it as an event whose id is the delivery id.

```csharp
app.MapInngestWebhook("/webhooks/stripe",
    WebhookProvider.Stripe(secret));
```

[Webhooks guide](https://damikun.github.io/inngest-dotnet/guides/webhooks.md)

#### Transactional outbox

Save an event in the same Entity Framework Core transaction as your data, and a hosted dispatcher sends it to Inngest afterwards.

```csharp
using Microsoft.EntityFrameworkCore;
db.AddInngestEvent(placed);
await db.SaveChangesAsync(cancellationToken);
```

[Transactional outbox guide](https://damikun.github.io/inngest-dotnet/guides/outbox.md)

#### User context

Carry the signed-in user who caused a run into it, as `ICurrentUser` or a `ClaimsPrincipal`, protected with ASP.NET Core Data Protection.

```csharp
inngest.AddUserContext(options =>
    options.Claims = ["sub", "role"]);
inngest.AddUserContextFromHttpContext();
```

[User context guide](https://damikun.github.io/inngest-dotnet/guides/user-context.md)

#### Middleware

Observe and wrap function runs, steps and event sends, for logging, metrics, tracing and error mapping.

```csharp
inngest.AddMiddleware<TimingMiddleware>();
```

[Middleware guide](https://damikun.github.io/inngest-dotnet/guides/middleware.md)

#### Encryption

Encrypt event data, step results and function output, in the format of the TypeScript SDK's encryption middleware.

```csharp
inngest.AddEncryption(options =>
    options.Key = encryptionKey);
```

[Encryption guide](https://damikun.github.io/inngest-dotnet/guides/middleware.md#encryption)

#### Sentry

Report the errors of functions and steps to Sentry, tagged with the app, the function, the run and the event.

```csharp
builder.WebHost.UseSentry(o => o.Dsn = dsn);
inngest.AddSentryReporting();
```

[Sentry guide](https://damikun.github.io/inngest-dotnet/guides/middleware.md#sentry)

#### OpenTelemetry

Trace every call and step, and measure runs, retries and durations.

```csharp
using OpenTelemetry.Trace;
inngest.AddInstrumentation();
tracing.AddInngestInstrumentation();
```

[OpenTelemetry reference](https://damikun.github.io/inngest-dotnet/reference/api.md#opentelemetry)

### Test and ship

#### Testing

Run a function in memory on the real execution engine with mocked steps, or against a fake `IStepTools`.

```csharp
using Inngest.Testing;
var mock = new StepMocks().Run("charge", "ok");
var run = await host.RunAsync("pay", e, mock);
```

[Testing guide](https://damikun.github.io/inngest-dotnet/guides/testing.md)

#### Testing with containers

Start the Inngest dev server or a self-hosted server in Docker from your tests, and run your app against it.

```csharp
using Inngest.Testing.Containers;
var server = new InngestServerBuilder(
    "inngest/inngest:v1.49.0").Build();
```

[Testing with containers guide](https://damikun.github.io/inngest-dotnet/guides/testing-with-containers.md)

#### Aspire

Run the Inngest dev server as a container of your AppHost, with its UI linked in the Aspire dashboard.

```csharp
var inngest = builder.AddInngest("inngest");
builder.AddProject<Projects.Shop>("shop")
    .WithReference(inngest).WaitFor(inngest);
```

[Aspire guide](https://damikun.github.io/inngest-dotnet/guides/aspire.md)

#### Source generator and analyzers

Turn on a generator that registers every function class of a project with one call, and find the types a Native AOT app has not listed.

```csharp
inngest.AddInngestFunctions();
```

[Source generator guide](https://damikun.github.io/inngest-dotnet/guides/source-generator.md)

#### Templates and the dotnet inngest tool

Start an app with `dotnet new inngest-web` or `inngest-worker`, and run the Inngest dev server for it from its folder with `dotnet inngest dev`.

```bash
dotnet new inngest-web -n Shop
dotnet tool install --global dotnet-inngest
dotnet inngest dev
```

[Templates and tool guide](https://damikun.github.io/inngest-dotnet/guides/templates-and-tools.md)

## Tested against the real Inngest server

Triggers, steps, sleeps, waits, flow control, retries, middleware, key rotation and Connect run end to end against Inngest's own server image, in dev mode and in signed cloud mode.

## Your first durable function in five minutes

Install the SDK, write a function and register it, start the dev server, and watch a run go step by step on your machine.

1. Install the SDK from NuGet. `Inngest.AspNetCore` brings in `Inngest`:

   ```bash
   dotnet add package Inngest.AspNetCore
   ```

2. Write the function, a class with one method. Its attributes give it an id and name the event that starts it.
   Each `RunAsync` call is a step: it runs once and Inngest saves its result. `SleepAsync` pauses the run without
   holding a thread:

   ```csharp title="OnboardUser.cs"
   using Inngest;

   public sealed record UserSignedUp(string UserId, string Email);

   [InngestFunction("onboard-user")]
   [EventTrigger("user/signed-up")]
   public sealed class OnboardUser : InngestFunction<UserSignedUp>
   {
       public override async Task<object?> ExecuteAsync(
           FunctionContext<UserSignedUp> context, CancellationToken cancellationToken)
       {
           var user = context.Event.Data;

           await context.Step.RunAsync("send-welcome-email",
               () => Console.WriteLine($"Sending a welcome email to {user.Email}"));

           await context.Step.SleepAsync("wait-a-moment", TimeSpan.FromSeconds(10));

           await context.Step.RunAsync("send-tips",
               () => Console.WriteLine($"Sending tips to {user.Email}"));

           return new { user.UserId };
       }
   }
   ```

3. Register the function and map the endpoint Inngest calls:

   ```csharp title="Program.cs"
   using Inngest;

   var builder = WebApplication.CreateBuilder(args);

   builder.Services
       .AddInngest(options => options.AppId = "shop")
       .AddFunction<OnboardUser>();

   var app = builder.Build();
   app.MapInngest("/api/inngest");
   app.Run();
   ```

4. Set `INNGEST_DEV=1`, which points the SDK at the local dev server, and start the app with
   `dotnet run --urls http://localhost:5280`. Then run the
   [Inngest dev server](https://www.inngest.com/docs/local-development):

   **Install script**

   ```bash
   curl -sSfL https://cli.inngest.com/install.sh | sh
   inngest dev -u http://localhost:5280/api/inngest
   ```

   **Homebrew**

   ```bash
   brew install inngest/tap/inngest
   inngest dev -u http://localhost:5280/api/inngest
   ```

   **npx**

   ```bash
   npx --ignore-scripts=false inngest-cli@latest dev -u http://localhost:5280/api/inngest
   ```

   **.NET tool**

   ```bash
   dotnet tool install --global dotnet-inngest
   dotnet inngest dev -u http://localhost:5280/api/inngest
   ```

   The tool downloads the Inngest CLI once, checks its SHA-256 and runs it. In a project with a launch profile, leave out `-u`: the tool reads the address from `Properties/launchSettings.json`.

   **Docker**

   ```bash
   docker run --rm -p 8288:8288 -p 8289:8289 inngest/inngest:v1.49.0 inngest dev -u http://host.docker.internal:5280/api/inngest
   ```

   The container reaches the app at `host.docker.internal`, since `localhost` inside it is the container itself. On Linux without Docker Desktop, add `--add-host=host.docker.internal:host-gateway`.

   **Compose**

   ```yaml title="compose.yaml"
   services:
     inngest:
       image: inngest/inngest:v1.49.0
       command: inngest dev -u http://host.docker.internal:5280/api/inngest
       ports:
         - "8288:8288"
         - "8289:8289"
       extra_hosts:
         - "host.docker.internal:host-gateway"
   ```

   ```bash
   docker compose up
   ```

5. Send a `user/signed-up` event. The dev server takes any event key in the URL. The run appears at
   `http://localhost:8288`, and the app prints the welcome line, then the tips 10 seconds later:

   ```bash
   curl -X POST http://localhost:8288/e/test -d '{"name": "user/signed-up", "data": {"userId": "u1", "email": "ada@example.com"}}'
   ```

[Getting started](https://damikun.github.io/inngest-dotnet/guides/getting-started.md) goes through the same steps in detail.

## Questions engineers ask first

### Is this an official Inngest SDK?

No. inngest-dotnet is a community SDK and is not affiliated with Inngest Inc. It speaks the same protocol as the official Inngest SDKs, and its end-to-end tests run against the Inngest server in dev and cloud mode.

### What do I need to run it?

.NET 8 or .NET 10 and an Inngest server: Inngest Cloud, a [self-hosted Inngest](https://www.inngest.com/docs/platform-and-operations/self-host-inngest), or the dev server on your machine. Functions are served from an ASP.NET Core endpoint, from Azure Functions or AWS Lambda, or from a [Connect worker](https://damikun.github.io/inngest-dotnet/guides/connect.md).

### Does Inngest have to reach my app over the internet?

With `MapInngest`, Inngest calls a public HTTPS endpoint in your app. With [Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md), the app opens a WebSocket to Inngest instead and needs no inbound port.

### Is it ready for production?

The serve protocol, steps, flow control, signing, middleware and Connect are implemented and verified: 3,735 unit tests, 351 end-to-end tests against the Inngest server in Docker, and load and memory checks in dev and cloud mode. The [readiness report](https://damikun.github.io/inngest-dotnet/internals/readiness.md) sums them up. The packages are on NuGet as 1.0.0. Durable endpoints are the one feature still marked experimental.

### Does it work with Native AOT?

Yes. The SDK uses source-generated JSON for its own types; add a `JsonSerializerContext` for yours, as described in [Native AOT and trimming](https://damikun.github.io/inngest-dotnet/guides/native-aot.md).

## Docs for this SDK and for Inngest

- [Guides](https://damikun.github.io/inngest-dotnet/guides/getting-started.md): Functions, steps, events, flow control, errors, middleware, Connect and deployment.
- [Examples](https://damikun.github.io/inngest-dotnet/examples/order-fulfillment.md): Order fulfillment, onboarding, nightly billing, webhooks and more, complete and explained.
- [Roadmap](https://damikun.github.io/inngest-dotnet/roadmap.md): What each release added, from the first preview to 1.0.0, and what comes next: guides, checkpointing by default and durable endpoints out of experimental.
- [Inngest documentation](https://www.inngest.com/docs): The platform: events, flow control, deployment, self-hosting and the dashboard.
- [AI coding assistants](https://damikun.github.io/inngest-dotnet/guides/ai-assistants.md): The names that differ from the TypeScript SDK and the rules that keep a function correct when it replays.

For tools that read the docs there are [`llms.txt`](https://damikun.github.io/inngest-dotnet/llms.txt) and
[`llms-full.txt`](https://damikun.github.io/inngest-dotnet/llms-full.txt), and every page has a Markdown copy at its
address with `.md` in place of the trailing slash.
