Design
Rendered from docs/design.md in the repository when the site was built.
This document describes how the .NET SDK is put together and the wire protocol it speaks. It is written for people working on the SDK; the README covers usage.
inngest-dotnet is a community Inngest SDK for .NET. It speaks the same protocol as Inngest’s official SDKs, checked against the server’s SDK spec and the server’s Go code, and compared with the official SDKs for TypeScript (inngest-js v4.26.2), Go, and Kotlin and Java. Where the spec and inngest-js disagree, the SDK does what the server actually relies on; those cases are listed in Spec deviations. The behavior that differs from inngest-js on purpose is in Differences from the TypeScript SDK.
It is built for .NET rather than translated to it. Functions are classes resolved from dependency injection, with their options as attributes, typed events and results, a dependency injection scope per step and settings from configuration. Packages connect it to ASP.NET Core, Azure Functions, AWS Lambda, Aspire, Entity Framework Core, SignalR, Blazor, Microsoft.Extensions.AI, OpenTelemetry, Sentry and Testcontainers. The rows marked .NET only in What is supported are the features particular to this SDK.
Protocol
Section titled “Protocol”Inngest talks to an app through one HTTP endpoint (by default /api/inngest). The method decides the action:
| Method | Caller | Purpose |
|---|---|---|
GET |
Inngest, dev server discovery, anyone | Introspection and health check |
PUT |
Inngest, the dev server, a deploy script | Sync: register the app’s functions |
POST |
Inngest executor | Call: run a function or one of its steps |
Every response to a verified request (and every response in dev mode) carries
X-Inngest-SDK: inngest-dotnet:v<version>. The server treats a 2xx without it as “not an SDK” and retries, which
matters behind proxies. Answers to unverified requests in cloud mode leave out the SDK headers on purpose, so they
tell an anonymous caller nothing.
Sync (PUT)
Section titled “Sync (PUT)”The X-Inngest-Sync-Kind request header picks the kind of sync.
Out-of-band (header absent or out_of_band): the SDK posts its configuration to
POST {api}/fn/register?deployId=<from the PUT query> with Authorization: Bearer <hashed signing key> and
X-Inngest-Expected-Server-Kind copied from the PUT’s X-Inngest-Server-Kind. It then answers the PUT with
200 {"message":"Successfully registered","modified":<from Inngest>} or 500 {"message":"<error>","modified":false},
plus X-Inngest-Sync-Kind: out_of_band. Out-of-band PUTs are accepted unsigned, as in the TypeScript SDK, because
the SDK only calls back the configured Inngest.
In-band (header in_band): the PUT body is {"url":"<serve url>"} and must be signed in cloud mode. The SDK
answers 200 with X-Inngest-Sync-Kind: in_band and the configuration in the body, so Inngest does not need to
reach the app twice. InngestOptions.AllowInBandSync = false forces out-of-band syncs, and
AllowUnsignedSync = false (INNGEST_ENABLE_UNAUTHED_SYNC=false) makes cloud mode refuse unsigned ones.
The configuration (the register body, and the functions of an in-band reply):
{ "url": "https://example.com/api/inngest", "deployType": "ping", "appName": "shop", "appVersion": "1.4.0", "sdk": "dotnet:v1.0.0", "v": "0.1", "framework": "aspnetcore", "capabilities": { "trust_probe": "v1" }, "functions": [{ "id": "shop-send-welcome-email", "name": "Send welcome email", "triggers": [ { "event": "user/signed.up", "expression": "event.data.plan == 'pro'" }, { "cron": "TZ=Europe/Prague 0 9 * * 1-5" } ], "steps": { "step": { "id": "step", "name": "step", "runtime": { "type": "http", "url": "https://example.com/api/inngest?fnId=shop-send-welcome-email&stepId=step" }, "retries": { "attempts": 3 } }}, "concurrency": [{ "limit": 10, "key": "event.data.accountId", "scope": "fn" }], "throttle": { "limit": 5, "period": "1m", "burst": 2, "key": "event.data.accountId" }, "rateLimit": { "limit": 1, "period": "4h", "key": "event.data.accountId" }, "idempotency": "event.data.orderId", "debounce": { "period": "5s", "key": "event.data.accountId", "timeout": "1m" }, "batchEvents": { "maxSize": 50, "timeout": "5s", "key": "event.data.tenant" }, "priority": { "run": "event.data.vip ? 120 : 0" }, "cancel": [{ "event": "user/deleted", "if": "event.data.userId == async.data.userId", "timeout": "1d" }], "timeouts": { "start": "10m", "finish": "1h" }, "singleton": { "mode": "skip", "key": "event.data.userId" } }]}Notes on the fields:
- A function’s id on the wire is
<app id>-<function id>, not slugified.nameis required by the server and defaults to the function id. retries.attemptsholds the number of retries, not attempts. When omitted the server uses 4 retries (5 attempts); the maximum is 20.- Durations are time strings (
"1h30m"), formatted fromTimeSpanwith whole-second resolution. Batch timeouts are parsed with Go’stime.ParseDuration, so they never usedorwunits. idempotencyis turned into a 24h rate limit by the server and replaces anyrateLimit.- A function with a failure handler registers a second function
<id>-failure, triggered byinngest/function.failedwithevent.data.function_id == '<app id>-<id>'and one retry.
Introspection (GET)
Section titled “Introspection (GET)”Returns schema_version: "2024-05-24". In cloud mode with a valid signature the body is the authenticated form
(app id, origins, hashed keys, function count, mode, SDK language and version); in cloud mode without one the SDK
answers 401, as the TypeScript SDK does. Dev mode verifies nothing, so it always gets the unauthenticated form
(function count, mode, which keys are set).
Call (POST)
Section titled “Call (POST)”Query parameters: fnId is the composite function id; stepId is step for a normal call or the hashed id of a
planned step to run. The body (keys arrive sorted, because Inngest canonicalizes it before signing):
{ "event": { "name": "user/signed.up", "data": { "userId": 1 }, "user": {}, "id": "01J...", "ts": 1705586504000 }, "events": [ "...all events of a batch, or just event..." ], "steps": { "e7d8a2f140845095749d60246ff1110c9d01d76a": { "data": "A" }, "2c4ac3...": { "error": { "name": "Error", "message": "boom", "stack": "..." } }, "8f1a2b...": null, "93bd77...": { "name": "order/paid", "data": {}, "ts": 1705586505000 }, "51c0de...": { "input": [] } }, "ctx": { "run_id": "01J...", "attempt": 0, "max_attempts": 5, "stack": { "stack": ["e7d8a2f1...", "2c4ac3..."], "current": 2 }, "disable_immediate_execution": false, "use_api": false, "fn_id": "<uuid>" }, "version": 2}A memoized step is read as: an object with only data is a result, only error is a final failure, only input
is not a result (input of a step still in progress), and anything else, including null and a raw event, is the
result itself. Sleeps memoize null; waitForEvent memoizes the matched event or null on timeout.
When ctx.use_api is true the server trimmed a large payload, and the SDK fetches the events from
GET {api}/v0/runs/{run id}/batch and the steps from GET {api}/v0/runs/{run id}/actions, authenticated with the
hashed signing key. Any failure there answers 500.
Responses to calls
Section titled “Responses to calls”| Status | Meaning | Headers |
|---|---|---|
200 |
The function returned; the body is its output as JSON | |
206 |
The body is an array of step operations | X-Inngest-No-Retry when a step errored |
500 |
Retriable function error; body {"name","message","stack"} |
X-Inngest-No-Retry: false, optional Retry-After |
400 |
Final function error (non-retriable, uncaught step failure, or last attempt) | X-Inngest-No-Retry: true |
401 |
Signature missing or invalid in cloud mode |
Every call response also carries X-Inngest-Req-Version: 2. In cloud mode it is signed with
X-Inngest-Signature using the key that verified the request.
Step operations (id is the hashed step id):
op |
Sent when | Shape |
|---|---|---|
StepPlanned |
A run or sendEvent step is found but not executed |
{id, op, name, displayName, opts: {type}} |
StepRun |
A step ran and succeeded | {id, op, name, displayName, data, timing} |
StepError |
A step threw and has attempts left | {id, op, name, displayName, error} |
StepFailed |
A step threw on its last attempt or threw NonRetriableException |
{id, op, name, displayName, error} |
Sleep |
SleepAsync, SleepUntilAsync |
{id, op, name: "<duration or RFC 3339 time>", opts: {duration}} |
WaitForEvent |
WaitForEventAsync |
{id, op, name: "<event>", opts: {event, timeout, if}} |
InvokeFunction |
InvokeAsync |
{id, op, opts: {function_id, payload: {data, user}, timeout}} |
Step ids are the lowercase hex SHA-1 of the id the code passed. A repeated id gets :1, :2, … appended before
hashing, in call order (my-step = e7d8a2f1... is the spec’s example for my-step-id).
Deciding what to answer
Section titled “Deciding what to answer”For stepId=step, the SDK replays the function, feeding it memoized results, until the function returns, throws,
or waits on steps that are not memoized. Then:
- If exactly one new step was found, it is a
runstep andctx.disable_immediate_executionis false, the SDK runs it right away and answers206 [StepRun](orStepError/StepFailed). - Otherwise it answers
206with all new steps. The server runs eachStepPlannedstep in its own call withstepId=<hash>and setsdisable_immediate_executionfor the rest of the parallel section. - If the function returns while new steps are pending (fired but not awaited), the steps are reported, not the return value.
For stepId=<hash>, the SDK replays until it reaches that step, runs it and answers with one operation. If the step
does not show up within 10 seconds, it answers 500 with X-Inngest-No-Retry: false (the server has no
StepNotFound op).
Memoized results are handed back in the order recorded in ctx.stack.stack, so races between parallel steps
replay the way they first resolved. If the code asks for steps in an order the stack cannot satisfy, the SDK logs
that the function changed between calls and falls back to the earliest memoized step that was asked for.
Retries
Section titled “Retries”Every queue item (the first call, each planned step, each discovery call) gets retries + 1 attempts, counted by
ctx.attempt from zero. A function error is final when it is a NonRetriableException, an uncaught
StepFailedException, or happens on the last attempt; the SDK answers 400 then. A RetryAfterException adds
Retry-After (seconds, or an RFC 3339 time), which the server clamps to between 1 second and 24 hours.
After the final failure Inngest emits inngest/function.failed with {function_id, run_id, error, event}, which
is what failure handlers run on.
Signing
Section titled “Signing”Requests from Inngest carry X-Inngest-Signature: t=<unix seconds>&s=<hex>. s is HMAC-SHA256 over the raw body
followed by t, keyed with the signing key minus its signkey-<env>- prefix, used as UTF-8 text (not decoded
from hex). Requests older or newer than 5 minutes are rejected. With a fallback key configured
(INNGEST_SIGNING_KEY_FALLBACK), a signature that fails with the current key is checked against the fallback, so
a key can be rotated without downtime.
Calls to the Inngest API authenticate with Authorization: Bearer signkey-<env>-<sha256 of the hex-decoded key>
and are retried once with the fallback key on 401 or 403.
In dev mode nothing is verified or signed.
Events
Section titled “Events”POST {event api}/e/{event key} with a JSON array of {name, data, user, id, ts, v}. The response is
{"ids": [...], "status": 200}; the body’s status decides success. The SDK sends
X-Inngest-Event-Id-Seed: <unix ms>,<base64 of 10 random bytes> once per send and keeps it across retries, so a
retried send creates the same event ids. Sends are retried up to 5 attempts with exponential backoff.
Modes and configuration
Section titled “Modes and configuration”| Setting | Environment variable | Default |
|---|---|---|
IsDev |
INNGEST_DEV (1/true, or the dev server URL) |
cloud mode |
EventKey |
INNGEST_EVENT_KEY |
none; any key works against the dev server |
SigningKey, SigningKeyFallback |
INNGEST_SIGNING_KEY, INNGEST_SIGNING_KEY_FALLBACK |
required in cloud mode |
Environment |
INNGEST_ENV |
the key’s environment |
BaseUrl, ApiBaseUrl, EventApiBaseUrl |
INNGEST_BASE_URL, INNGEST_API_BASE_URL, INNGEST_EVENT_API_BASE_URL |
https://api.inngest.com, https://inn.gs, or http://localhost:8288 in dev mode |
ServeOrigin, ServePath |
INNGEST_SERVE_ORIGIN, INNGEST_SERVE_PATH |
the URL the request arrived on |
AllowInBandSync |
INNGEST_ALLOW_IN_BAND_SYNC |
true |
AllowUnsignedSync |
INNGEST_ENABLE_UNAUTHED_SYNC |
true |
Options set in code win over environment variables. Variables are read through IConfiguration, so they can
also come from appsettings.json or any other configuration source. AddInngest(IConfiguration) reads the options
from a section such as Inngest as well, as the lowest layer: code wins over the INNGEST_* variables, which win
over the section, which wins over the defaults. The section is read key by key rather than through the configuration
binder, so it works trimmed and under Native AOT.
.NET architecture
Section titled “.NET architecture”Packages
Section titled “Packages”| Package | Contents |
|---|---|
Inngest |
Client, function definitions, step tools, execution engine, middleware, signing, the framework-agnostic serve handler. Depends only on Microsoft.Extensions.* abstractions. It also carries a source generator, analyzers and a code fix (see Source generator and analyzers), which are not packages of their own. |
Inngest.AspNetCore |
MapInngest, which adapts ASP.NET Core requests to the serve handler, and AddInngest on IHealthChecksBuilder, a readiness check for apps that serve functions. |
Inngest.AwsLambda |
InngestLambdaHandler, which adapts the events of API Gateway REST and HTTP APIs and Lambda function URLs to the serve handler, in a managed runtime function or a Native AOT executable. Depends on Amazon.Lambda.APIGatewayEvents and Amazon.Lambda.Core only. |
Inngest.AzureFunctions |
ServeInngestAsync, which adapts the request of an HTTP-triggered function of the Azure Functions isolated worker to the serve handler. Depends on Inngest.AspNetCore and, of the worker, only Microsoft.Azure.Functions.Worker.Core. |
Inngest.Connect |
AddConnect, a hosted worker that receives calls over an outbound WebSocket instead of an HTTP endpoint. |
Inngest.Aspire.Hosting |
AddInngest and AddInngestSelfHosted, which run the Inngest dev server or a self-hosted server as a container of an Aspire AppHost, WithApp, which tells the server where an app that serves functions is, and WithReference, which gives a project the INNGEST_* settings the SDK reads. Depends on Aspire.Hosting only, not on Inngest. |
Inngest.Testing |
InngestTestHost, which runs functions in memory on the real execution engine for unit tests, with mocked steps, retries and replays. |
Inngest.OpenTelemetry |
AddInstrumentation, a middleware with spans for calls and executed steps and metrics for runs, steps, retries and durations. Depends on OpenTelemetry.Api only. |
Inngest.Encryption |
AddEncryption, a middleware that encrypts event data, step results and function output in the format of @inngest/middleware-encryption. XSalsa20, Poly1305 and BLAKE2b come from BouncyCastle.Cryptography, which is managed code. |
Inngest.Sentry |
AddSentryReporting, a middleware that reports the errors of functions and steps to Sentry, with tags for the call and the run’s trace. Depends on Sentry only. |
Inngest.Extensions.AI |
UseInngestSteps and UseInngestFunctionInvocation, stages for the ChatClientBuilder of Microsoft.Extensions.AI that make each model call and each tool call of a run an AI wrap step, and InngestInferChatClient, a chat client of inference steps in the OpenAI chat format. Depends on Microsoft.Extensions.AI. |
Inngest.EntityFrameworkCore |
AddEntityFrameworkOutbox, a transactional outbox: events are rows saved with the app’s data through DbContext.AddInngestEvent, and a hosted dispatcher sends them. Depends on Microsoft.EntityFrameworkCore.Relational, version 8 for net8.0 and 10 for net10.0. See Transactional outbox. |
Inngest.Testing.Containers |
A Testcontainers module (InngestServerBuilder, InngestServerContainer, InngestServerConfiguration) that runs the dev server or the self-hosted server (inngest start) in Docker for tests, with the settings that point an app at it and a call that has it sync the app. Depends on Testcontainers and Inngest’s public options only. |
Inngest.Testing.Containers.AspNetCore |
InngestWebApplicationFactory, a WebApplicationFactory that runs the app on Kestrel against the container. Depends on Microsoft.AspNetCore.Mvc.Testing, so it is a package of its own and targets net10.0 only. |
Inngest.Templates |
The inngest-web and inngest-worker templates for dotnet new. A template package: files, no assembly. |
dotnet-inngest |
A .NET tool, dotnet inngest dev, that downloads the Inngest CLI, checks it and runs the dev server. Depends on nothing but the framework. |
Inngest.SignalR |
AddInngestSignalR, MapInngestRealtimeHub and InngestRealtimeBridge<THub>, which forward realtime messages to SignalR clients through IHubContext<THub>: clients join through a hub method guarded by a callback, share one subscription per channel and topics, and get each message on a queue of their own, by connection id and never through a group, so that instances on a backplane deliver once. Reads the realtime subscriber of Inngest through InternalsVisibleTo, takes RealtimeSubscription from Inngest.AspNetCore, and uses the SignalR of the ASP.NET Core shared framework. |
Inngest.Blazor |
RealtimeView, a headless Blazor component, and IRealtimeSource, the realtime messages as an async stream, with a source for the server (AddInngestRealtimeServerSource, over IInngestRealtime) and one for WebAssembly (AddInngestRealtimeRelaySource, which reads a MapInngestRealtime endpoint). A Razor class library that references Microsoft.AspNetCore.Components.Web as a package, not the shared framework, so it runs in the browser. |
Inngest.UserContext |
AddUserContext, a middleware that attaches selected claims of the current user to the events an app sends, protected with ASP.NET Core Data Protection, and restores them in runs as ICurrentUser and ClaimsPrincipal. Depends on Microsoft.AspNetCore.DataProtection, which has no framework reference, so workers without ASP.NET Core use it. |
Inngest.UserContext.AspNetCore |
AddUserContextFromHttpContext, which takes the user of an event sent while serving a request from HttpContext.User. Uses the ASP.NET Core shared framework. |
Inngest.Webhooks |
Verifiers for Standard Webhooks (Svix, Clerk, Resend), Stripe, GitHub, Shopify and any HMAC, the providers that turn a verified delivery into an event, and the pipeline behind MapInngestWebhook (which is in Inngest.AspNetCore). Depends on Inngest only, and uses System.Security.Cryptography for every signature. |
Inngest.AspNetCore, Inngest.AwsLambda, Inngest.Blazor, Inngest.Connect, Inngest.SignalR, Inngest.Testing,
Inngest.Encryption, Inngest.EntityFrameworkCore, Inngest.Extensions.AI and Inngest.UserContext use internals of
Inngest through InternalsVisibleTo: the serve handler, the execution engine, the registry and the resolved settings,
of which Inngest.Encryption only reads the serializer options, Inngest.EntityFrameworkCore those and the checks the
client makes on an event it sends, Inngest.Extensions.AI only asks which invocation the calling code runs in, and so
whether it can make a step, and Inngest.UserContext reads the settings, the registry and the run a piece of code
belongs to. Inngest.Blazor and Inngest.SignalR use the realtime subscriber, and Inngest.Blazor
also its message reader and server-sent events parser. Internals can change in any release, so the packages ship
together and each depends on exactly the Inngest of its own version (a target in src/Directory.Build.targets writes
the exact range). Inngest.OpenTelemetry and Inngest.Sentry use only the public middleware API, and
Inngest.Testing.Containers only InngestOptions; Inngest.Testing.Containers.AspNetCore reaches one internal helper
of Inngest.Testing.Containers through InternalsVisibleTo, and depends on exactly its version too.
Inngest.AzureFunctions uses the serve handler of Inngest and the InngestEndpoint of Inngest.AspNetCore in the
same way, so it depends on exactly the Inngest.AspNetCore of its own version too, as Inngest.AspNetCore does on the
Inngest.Webhooks whose internal receiver MapInngestWebhook uses. Package validation compares every
package with the last release on nuget.org, so dotnet pack fails on a change that would break code built against it;
PackageValidationBaselineVersion in src/Directory.Build.props is raised after each release.
Every package with an assembly is IsAotCompatible except four. The two container packages: Testcontainers and
Microsoft.AspNetCore.Mvc.Testing are built on reflection, and the packages are for test projects, which are neither
trimmed nor compiled ahead of time. Inngest.EntityFrameworkCore: the constructors of EF Core’s DbContext require
unreferenced and dynamic code. And Inngest.Aspire.Hosting, below.
Inngest.Aspire.Hosting is a different kind of package: it runs in the AppHost, not in the app, so it does not
reference Inngest and is not pinned to its version. It is not IsAotCompatible, because Aspire.Hosting declares
neither trimming nor Native AOT support and an AppHost is never published as a native binary. Aspire
reserves the Aspire. prefix on nuget.org, hence the name.
Code layout
Section titled “Code layout”- Namespaces. Public types are in
Inngest, whatever folder they live in, so oneusing Inngest;brings in the whole API. Internal types are in the namespace of their folder, such asInngest.ExecutionorInngest.Registration. Extension methods on a framework type are in that type’s namespace (Microsoft.Extensions.DependencyInjection,Microsoft.AspNetCore.Builder,OpenTelemetry.Trace), and extension methods on Inngest types are inInngest.Inngest.Aspire.Hostingfollows Aspire instead: its resource type is inAspire.Hosting.ApplicationModeland its extension methods are inAspire.Hosting, the two namespaces an AppHost imports. - Files. Every public type has a file named after it; the generic forms of a type share it or use
OfT, as inInngestEventOfT.cs. Internal helper types may share the file of the type they serve. A class too large to read in one file is split into partial files named after their concern, such asInvocation.Steps.cs,Invocation.Execution.csandInvocation.Answers.cs. - Comments. Public types and members carry a
/// <summary>, which IDEs show. Everything else has//comments that say why, not what. - Tests. The folders of
tests/Inngest.Testsfollow the areas of the source (Steps,Functions,Middleware,Registrationand so on), and the integration tests are grouped by the Inngest server fixture they run against.
Public API
Section titled “Public API”The API follows .NET conventions rather than the shape of the TypeScript SDK:
services.AddInngest(options => ...), orservices.AddInngest(configuration.GetSection("Inngest")), registers everything and returns anIInngestBuilderforAddFunction<TFunction>(),AddFunction<TFunction>(FunctionOptions),AddFunction<TEventData>(FunctionOptions, handler)andAddMiddleware<TMiddleware>().app.MapInngest("/api/inngest")serves the endpoint.- Functions are classes deriving from
InngestFunction<TEventData>, orInngestFunction<TEventData, TResult>for a typed result, resolved from a DI scope per invocation, or delegates.FunctionOptionsand the flow control records (ConcurrencyLimit,Throttle,RateLimit,Debounce,BatchEvents,Priority,CancelOn,FunctionTimeouts,Singleton) are immutable records withTimeSpandurations. - A function class can carry its options as attributes (
[InngestFunction],[EventTrigger<T>],[CronTrigger],[Concurrency],[Throttle],[CancelOn<T>]and so on), one perFunctionOptionsmember, with durations as time strings. All but[InngestFunction]derive fromFunctionOptionsAttributeand map themselves onto the options inConfigure, so users can write their own.AddFunction<TFunction>()reads them once at registration, withGetCustomAttributeson the type it names, which stays trim and AOT safe: it starts from[InngestFunction]and applies the others inOrder, then in declaration order. The result goes through the same startup validator. ExplicitFunctionOptionsreplace the attributes, andAddFunction<TFunction>(configure)adjusts them. There is no assembly scanning: the source generator writesAddInngestFunctions(), which callsAddFunction<TFunction>()for each class with[InngestFunction], in a project that turns it on. The generated code does not callApplyitself, so the attributes are still read when the app starts and not at compile time. - Event data records implement
IInngestEventData, whose static abstractEventName(and static virtualEventVersion) letSendAsync<T>,Trigger.Event<T>,WaitForEventAsync<T>,CancelOn.For<T>andSendEventAsync<T>take the name from the type without reflection. Every string form stays. step.InvokeAsync<TFunction, TResult>finds the function’s id in the registry by its class;IReturns<TResult>, whichInngestFunction<TEventData, TResult>implements, lets the compiler check the result type.FunctionContext<TEventData>gives the typed event and batch, run id, attempt, step tools, a replay-safeILoggerand the invocation’sIServiceProvider.- Step tools are an interface,
IStepTools, so user code can be unit tested with a fake:RunAsync,SleepAsync,SleepUntilAsync,WaitForEventAsync,InvokeAsync,SendEventAsync. Members added later have default implementations, and convenience overloads are extension methods over those six. Parallel steps are plainTask.WhenAll/Task.WhenAny. - Fan-out over a list is extension methods over those six:
MapAsync,ForEachAsyncandMapOutcomesAsyncrun a step per item with a window of items in flight, andMapInChunksAsyncandMapOutcomesInChunksAsyncsend long lists to child runs of aFanOutFunction<TItem, TResult>class. See Fan-out. RunScopedAsyncis a run step whose handler runs in a dependency injection scope of its own. The invocation makes the scope from its services (a sibling of the invocation’s scope, asIServiceScopeFactorymakes them) when the handler starts and disposes it, asynchronously, when the handler ends, so aDbContextis new for every attempt and step, and a memoized, planned or mocked step makes none. It is aRunStep<T>with a wrapped handler, so Inngest, middleware (StepType.Run) and the test host see an ordinary run step, and middleware keeps seeing the invocation’s scope inFunctionContext.Services. The typed overloads are extension methods that resolve one service withGetRequiredService<T>(). A scoped service that throws when it is disposed is logged and does not fail the step, so disposal must not do work that can fail.- Errors are exceptions:
NonRetriableException,RetryAfterException, andStepFailedExceptionthrown at a step’s call site when the step failed for good.InngestExceptioncovers failures talking to Inngest. - Failure handlers implement
IFailureHandleron the function class, or passonFailurewith a delegate. - Middleware derives from
InngestMiddlewareand overrides only the hooks it needs: observers (OnRunStartAsync,OnMemoizationEndAsync,OnRunCompleteAsync,OnRunErrorAsync,OnStepStartAsync,OnStepCompleteAsync,OnStepErrorAsync) and wrappers (WrapFunctionAsync,WrapStepAsync,WrapStepHandlerAsync,WrapSendEventAsync). Instances are resolved per call, so they can hold state for the call, as in v4. A run’s calls can reach any instance of the app, so nothing in them lasts the run.
Request path
Section titled “Request path”Kestrel -> MapInngest -> InngestEndpoint (pooled body) -> InngestCommHandler GET -> introspection PUT -> FunctionRegistry.WriteRegistration -> in-band body, or IInngestApi.RegisterAsync POST -> signature check -> FunctionRegistry.TryGet(fnId) -> ExecutionEngine.ExecuteAsync -> ServeResponseAzure Functions reaches the same handler through a function of the app that calls request.ServeInngestAsync(). With
the isolated worker’s ASP.NET Core integration, the function runs inside a request of the worker’s Kestrel, whose
HttpContext is in FunctionContext.Items, and InngestEndpoint.HandleAsync answers through it, streaming included.
With the built-in HTTP model, the body is a MemoryStream that already holds the request and is used in place, and the
response is copied into an HttpResponseData, which the worker sends to the host when the function returns, so a call
cannot be streamed.
InngestCommHandler knows nothing about ASP.NET Core; it works on IServeRequest and returns ServeResponse, so
other hosts, and the Connect worker, reuse it. Startup data (resolved settings, the function registry) is immutable and
read without locks. Request and response bodies live in pooled buffers; the body’s raw bytes are what the
signature is computed over and what JsonDocument parses, without copies.
Execution engine
Section titled “Execution engine”Each call replays the function from the top. The interesting part is knowing when the user’s code has gone as far
as it can in this call. The TypeScript SDK approximates that by waiting out a fixed number of microtask turns.
The .NET engine runs the user’s code on a private SynchronizationContext for the invocation
(InvocationScheduler), so every continuation of the function is queued there. When that queue is empty and the
function has not finished, the function is waiting on something: a step that will not complete in this call, or
real I/O whose continuation will arrive later. That is a precise signal, not a timeout, and it keeps
Task.WhenAll batching working, and Task.WhenAny races replaying with the same winner, with no special API. For
Inngest to call the function again as soon as the first step of a race finishes, the race runs in race mode
(ParallelAsync). One limit: a function that keeps
queueing work without ever waiting (a tight Task.Yield loop) is cut off after 100,000 continuations per round so
that a call always ends; memoized results may then be handed out before the function idles.
From there the engine follows the TypeScript SDK:
- Step tools hash the id and return a task the engine controls.
- Memoized results are handed out one at a time, in
ctx.stack.stackorder, each followed by running the function until it idles again. - When nothing memoized is left to hand out, the steps found since the last report decide the answer, as described in Deciding what to answer.
- Steps that were found but not run are left pending. Their tasks are never faulted or cancelled, so
catchandfinallyblocks in user code do not run for them in this call, matching the TypeScript SDK.
User code that leaves the scheduler (ConfigureAwait(false), Task.Run) still works: step calls made from other
threads are posted back to the scheduler, so engine state is only touched by one logical thread and needs no locks.
Such code can make the SDK report fewer steps at once. With an id that parallel branches share, it can also change
which branch gets which result, since a repeated id is numbered in the order the steps reach the scheduler, as the
branches’ own I/O can do too. The step tools register a step when it is called, a fetch with a body that is not
buffered included: it holds every decision of the call until the body has been read, so that it is reported with the
steps found with it. Blocking on a step task (.Result) deadlocks the invocation, as it would on any single-threaded
context.
Two rules keep tasks the function starts from hanging, as they would not on the JavaScript event loop:
- While a step handler or a middleware hook runs, the engine keeps running what is queued on the scheduler, without deciding anything, so either can await a task the function started before the step.
- Once the call has answered, work posted to the scheduler, and work still queued, runs on the thread pool instead
of being dropped. A task the function started may outlive the call, such as one a singleton caches, or a
SemaphoreSlimwait whosefinallyreleases it. The abandoned function’s own steps still never complete.
Fan-out
Section titled “Fan-out”MapAsync and its siblings are extension methods over IStepTools, written against nothing but RunAsync and
InvokeAsync<TFunction, TResult>, so a fake of the step tools runs them too.
- Ids. An item is the step
<id>[<index>]and a chunk is<id>[<first>-<last>]. The engine appends:<n>to a repeated id, so<id>:<index>could be taken by the second use of<id>in a run. The ids here end in], which that suffix never does, and the index is the run of digits after the last[, so two items or an item and a chunk never share an id. What can still repeat is an id the code passes twice, and the engine numbers that as ever. A chunk has both of its ends in its id so that a run replayed with another chunk size does not hand one chunk the result of another; it invokes every chunk again instead. - The window.
FanOutRunstarts one lane per unit in flight, as the workers ofParallel.ForEachAsyncdo: a lane starts a unit, waits for it, and takes the next. The engine hands memoized results back in the order the run recorded them, one at a time, and each hand-back lets its lane start the next unit, so a replay starts the units in the order the run first did, whichever order the results arrived in. Under the default mode Inngest calls the function again only when every step of a group has finished, so the window moves on in groups; under race mode after each step, so it slides. The engine tells that the function is idle on its scheduler, so the lanes run there: they await withConfigureAwait(true)on purpose, sincefalsewould move their continuations to the thread pool, and the next unit would start while the engine decides what to report. A caller that left the scheduler, afterConfigureAwait(false)or insideTask.Run, hasStepTools.RunOnSchedulerqueue the start of the lanes there with its execution context, as one piece of work, so that the whole first window is found at once and a fan-out behaves as it does called from the scheduler. The alternative, lanes that continue on the thread that completes a step (ContinueWithwithExecuteSynchronously), fixes the awaits but leaves the first window to be registered step by step from the pool thread, which the engine can interleave with hand-backs: in 60,000 replays of calls off the scheduler it gave one window in another order and, once, another first failure, both fromTask.Run; queuing the start gave none in as many. A continuation that is not inlined would also run on the pool. A fake of the step tools has no scheduler, and the lanes then start where the caller is. The answer is taken where the run is decided, not when the awaiting code resumes. - Calls. A list of
nitems in windows ofwtakes one call per window of two or more items that finds its items, one call per item, one call for a last window of one item that runs in the call that finds it, and one that returns: seven items in windows of three are 10 calls. In race mode it is2n + 1. Every call replays the function and carries the results so far, which is why lists beyond a few hundred items go to chunks. - Failures. A fail-fast run starts nothing after the first failure it sees and throws it without waiting for the units in flight, since a planned step cannot be recalled. The failure it sees first is the one Inngest recorded first, because hand-backs follow the record. A step that failed for good is the only thing that counts as an item’s failure; anything else a unit throws ends the run with that exception.
- Chunks. The function of a chunk is a class, because the code of an item cannot travel to another run. The parent
invokes it by class with a
FanOutChunk<TItem>as the event data and readsFanOutOutcome<TResult>[]back; the function never fails because of an item, it reports it, so the parent can name the item that failed. The outcomes are an array because the engine writes a function’s output by its runtime type, so a source-generated context has to list the type that is written. Sizes follow the limits of Inngest, 32 MiB for a run’s state among them, and what the dev server enforces: a run fails once 1,000 steps have completed, so the guards refuse 1,000 items, chunks or chunk size and more, without retries, and an SDK response is capped at 8 MiB, which carries all the chunks planned in one call and the outcomes a chunk’s run ends with. See the guide for what was tried.
Serialization
Section titled “Serialization”The SDK’s own wire types use System.Text.Json source generation, one JsonSerializerContext per area. User types
(event data, step results, function output) go through InngestOptions.SerializerOptions, which defaults to
JsonSerializerDefaults.Web with reflection when reflection is enabled. Apps that trim or use Native AOT add their
own source-generated context to its TypeInfoResolverChain, and an analyzer of the Inngest package finds the types
that context lacks. Step results are round-tripped through JSON even on the call that ran them, the same as every later
replay sees them. Packages that save types the app does not own, such as Inngest.Extensions.AI with ChatResponse,
write them to a JsonElement with the options of the library the types come from and hand the SDK only the element, so
the app’s options need no contracts for them.
Source generator and analyzers
Section titled “Source generator and analyzers”src/Inngest.SourceGeneration holds an incremental source generator and two analyzers, and
src/Inngest.SourceGeneration.CodeFixes the code fix. Neither is a package. src/Inngest/Inngest.csproj builds both
without referencing them and packs the two assemblies in analyzers/dotnet/cs, with buildTransitive/Inngest.props
and Inngest.targets. They ship in the package of the SDK because the generated code calls AddFunction<T>() of the
same release and every project that defines a function references Inngest, which is where System.Text.Json and
Microsoft.Extensions.Logging put their generators too, next to the attributes they read. martinothamar/Mediator
ships its generator as a package of its own, because only the app that hosts the mediator needs it and libraries use
the abstractions alone; that is not the shape here, where a library with functions needs the generator as much as the
app does. buildTransitive and not build, because an app mostly reaches Inngest through Inngest.AspNetCore or
Inngest.Connect, and the assets of build only reach the project that references the package itself.
Everything in the package reaches every existing user on upgrade, so nothing it does may change a build that compiles
today. The registration is opt-in: InngestGenerateFunctionRegistration is false until a project sets it, and the
generator then writes nothing and the class diagnostics are not reported. Without that, a project that registers a
generic class closed by hand, or a private class from the class that holds it, would have warnings (errors, under
warnings as errors) about code that works, and a helper of its own called AddInngestFunctions would become ambiguous the
moment it had a class with [InngestFunction]. The JSON analyzer is limited by what it checks: it is silent unless the
project turns reflection-based JSON off, and then it reports types that would fail at run time.
DisableInngestAnalyzers removes everything the package runs in the compiler, for the build that one of them breaks.
- Roslyn. Both assemblies target
netstandard2.0(RS1041) andMicrosoft.CodeAnalysis.CSharp4.8.0, the compiler of the first .NET 8 SDK and Visual Studio 17.8, the oldest that can build fornet8.0. A component loads into its own Roslyn version or a newer one, so one build serves every compiler the packages can be used with, where System.Text.Json’s generator ships builds for Roslyn 3.11, 4.0 and 4.4 underanalyzers/dotnet/roslyn*because its package also targets older runtimes.EnforceExtendedAnalyzerRulesis on, so the banned-API rules (RS1035) apply. - The code fix is an assembly of its own. It needs
Microsoft.CodeAnalysis.Workspaces, which the command-line compiler does not have, and a generator or analyzer must not reference it (RS1038): the extension would fail to load there. The compiler never loads a type of the code fix assembly, so the assembly beside the generator is harmless to a build, and the IDE uses it. - The generator asks for the classes with
ForAttributeWithMetadataName, which the compiler answers from an index of attribute names, and describes each one as a string: no symbol, compilation or location enters the pipeline. OneRegistrationvalue, which compares by value (EquatableArray<string>), comes out ofCollect(), so an edit that leaves the registered classes as they were does not write the file again.IncrementalityTestscheck that withtrackIncrementalGeneratorSteps: every step’s outputs areCachedorUnchangedafter an unrelated edit. - Output. A static class in the namespace
Inngest, named after the assembly, with a hash of the name added when an identifier cannot hold all of it: two assemblies of one app each get a class, and a public class of the same name in both would hide the extension methods of the one that is referenced (the compiler only warns, CS0436), whichMy-LibandMy.Libwould do without the hash. One statement for each class, because a long chain of calls is one expression and the compiler has a limit on those. The file disables CS0612 and CS0618, because a function class may be[Obsolete]and the warning would be in a file nobody can edit. The method isinternalunless the project sayspublic, and its name is a project property too. With public methods of the same name in two assemblies, or an internal one seen throughInternalsVisibleTo, the call is ambiguous (CS0121);InngestFunctionsMethodNameis the way out. - Diagnostics. The generator reports nothing about classes.
FunctionClassificationanswers whether a class can be registered, for the generator, which leaves out the classes that cannot, and forFunctionClassAnalyzer, which says why, when the project has turned generation on. The two cannot disagree, and the messages are analyzer diagnostics, which#pragmaand.editorconfigcontrol. A property value the generator cannot use is reported by the generator. - JSON analyzer. It registers nothing unless
PublishAot,PublishTrimmedorJsonSerializerIsReflectionEnabledByDefault(read throughCompilerVisibleProperty) say that reflection-based JSON is off; the SDK only flips the switch while publishing, so the properties it flips it from are read, and they are those of the project it compiles, so a library that sets none is not checked. It checks the type arguments of the function base class,AddFunction<TEventData>(options, handler)and the typed steps (RunAsync<T>,AIWrapAsync,WaitForEventAsync<T>,WaitForSignalAsync<T>,InvokeAsync,InferAsync<T>andPublishAsync<T>, which read their type back through the app’s serializer options). - What counts as listed is what the generator of System.Text.Json writes metadata for, and the analyzer follows its
rules: the types of public properties and fields, and of non-public ones with
[JsonInclude], minus[JsonIgnore]; the parameters of the one constructor it calls ([JsonConstructor], else the only public one when there is no public parameterless one); the element type of a collection, whose own members and constructors it ignores; the type arguments of framework generics;[JsonDerivedType]. A type with a[JsonConverter]has nothing reached through it. The roots are the types a context lists, in the project and in referenced assemblies that use System.Text.Json and are not part of the framework, plus the typesPublicTypesJsonContextlists, the context the SDK appends to every app’s options, so thatstringorIReadOnlyList<string>is not asked for when the app’s context reaches none.JsonCoverageTestshold the analyzer to this: they compile a set of probe types, one for each rule, and compare the analyzer with the generator of every .NET version they can find in the targeting packs (8 and 10 agree), and compare the types taken from the SDK’s context with the ones that context has. The set is computed when the first type needs it. - The generator cannot fill the context. Generators run on the same input and the output of one is not input to
another, so
[JsonSerializable]attributes written by Inngest’s generator would never reach System.Text.Json’s. The code fix edits the user’s own file instead, withImportAdderand the simplifier for the using directives. Its equivalence key names the context and not the type, and its ownFixAllProvideradds every type of the scope to that context in one edit: the batch fixer merges text changes, which collide when several diagnostics edit the same class. - The package path is tested. Everything above runs from the build output in the unit tests, which is not how a user
gets it.
PackagedConsumerTestspackInngestandInngest.AspNetCoreinto a temporary feed, restore them into a small app that references onlyInngest.AspNetCore, and build it in each state: nothing set (no diagnostic, under warnings as errors), generation on, reflection off, andDisableInngestAnalyzers. A CI job runs them with the .NET 8 SDK too.
Logging
Section titled “Logging”ILogger with source-generated LoggerMessage methods throughout. FunctionContext.Logger drops messages while
the function replays memoized steps, so a message written between two steps appears once per run rather than once
per call.
Webhooks
Section titled “Webhooks”MapInngestWebhook turns a provider’s webhooks into events. The work is split so that nothing but the last step knows
about ASP.NET Core:
Kestrel -> MapInngestWebhook -> WebhookEndpoint (bounded, pooled body) -> WebhookReceiver size check -> IWebhookVerifier.Verify -> JsonDocument.Parse -> WebhookProvider.CreateEvent -> WebhookOptions.Transform -> IInngestClient.SendAsync (within SendTimeout) -> status code- Package.
Inngest.Webhooksholds the verifiers,WebhookProvider,WebhookOptionsandWebhookReceiver, and depends onInngestalone.Inngest.AspNetCorereferences it and adds the endpoint, asInngestCommHandlerinInngestis reached byMapInngestand by the Connect worker. The verifiers work onIWebhookRequest(the raw bytes and a header lookup), so an Azure Functions or a Lambda host can reuse them.WebhookReceiveris internal for now; such a host getsInternalsVisibleToor the type becomes public when it needs it.Inngestitself gained no API. - Verification is in front of everything: the body is not parsed, and no header is used for anything but the
signature’s own inputs, until a signature matches. Signatures are decoded without exceptions, compared with
CryptographicOperations.FixedTimeEqualsagainst every candidate and every secret (no early exit), and the timestamp window is checked after the signature, so the log can tell a replay from a forgery. A secret never appears in a message, and neither does a body, a signature or a header. - Schemes. Standard Webhooks signs
{id}.{timestamp}.{body}with HMAC-SHA256 and the base64 key behindwhsec_, and carries severalv1,<base64>signatures for rotation;v1a(Ed25519) is ignored, because .NET has no Ed25519 in its libraries. Stripe signs{t}.{body}with the text of the secret and carries onev1per active secret. GitHub and Shopify sign the body only, so they have no replay window and their headers are not covered.HmacWebhookVerifierexpresses all of them, and Slack’sv0:{timestamp}:{body}, as options; GitHub and Shopify are two presets of it. - Events. The default mappings follow Inngest’s own transforms where its guide has one (
stripe/<type>with the event as data and itsidas the id;<prefix>/<type>with thedataof the payload for Clerk and Resend). The data is aJsonElementof the payload, which the client writes through the serializer options withJsonElementmetadata thatPublicTypesJsonContextalways adds, so it needs no reflection, and nothing from a header is put in it: only the body is signed by GitHub and Shopify, so their event names and ids, which come from headers, are checked for their shape (short visible ASCII, the whole name at most 256 characters) but remain whatever a holder of one captured delivery sends. A provider that has delivery ids needs one: a delivery without is a 400, since an event without an id would never be deduplicated. - Lifetime. The parsed payload reads the pooled body in place. Before the send, a
JsonElementas the event’sDataorUseris cloned, so that the event can be kept (the test host’sSentEventsand middleware do), and the pooled body is cleared and returned when the request ends. - Answers have no body: 200, 204 (the transform dropped it), 401, 400, 413, 503 (Inngest did not accept the event
within
SendTimeout, which is short because providers wait seconds, or failed in a way that may pass) and 500 (a verifier or a transform threw). A send that may pass is a 5xx on purpose: a provider that retries delivers the webhook again and the delivery id makes a second attempt a duplicate that Inngest drops. A send that cannot pass is not: anInngestExceptionwith a 4xx other than 408, 425 and 429 (the statuses the client itself retries) is answered 413 for 413 and 400 otherwise, a client refusal (ArgumentException) is a 400, and a payload that parses but cannot be written (an unpaired surrogate escape) is found by writing it once before the send and is a 400; each is logged as an error that says a redelivery fails the same way.
What is supported
Section titled “What is supported”| Area | Status |
|---|---|
| Serve endpoint: introspection, in-band and out-of-band sync, calls | Supported |
| Dev mode and cloud mode, signing, signing key rotation, response signing | Supported |
| Steps: run, sleep, sleepUntil, waitForEvent, invoke, sendEvent, parallel steps | Supported |
A dependency injection scope per step handler, for a DbContext and other scoped services |
Supported (.NET only): RunScopedAsync and typed overloads on the step tools |
| Flow control and function config: concurrency, throttle, rate limit, debounce, batching, priority, idempotency, retries, cancelOn, timeouts, singleton | Supported |
| Function config as attributes on the class, typed events, typed results and invoke by class | Supported (.NET only) |
Settings from a configuration section (appsettings.json), below INNGEST_* variables |
Supported (.NET only) |
Failure handlers (inngest/function.failed) |
Supported |
| Errors: non-retriable, retry-after, step failures | Supported |
| Middleware: observer and wrap hooks, for the whole app or for one function | Supported |
use_api for large runs |
Supported |
| Checkpointing (running several steps per call and saving each to Inngest as it completes) | Supported, opt-in with CheckpointingOptions; off by default |
| Connect (WebSocket workers): start, handshake, heartbeats, leases, reply buffering and flush, draining, reconnects, graceful shutdown | Supported (Inngest.Connect) |
| Connect connection state and a readiness health check | Supported (IInngestConnection, AddInngestConnect) |
| Serve health check: keys configured, the API answers a request that carries the signing key, optionally the app is synced | Supported (AddInngest on IHealthChecksBuilder, InngestHealthCheckOptions) |
Aspire AppHost: the dev server and a self-hosted server as containers, apps synced at the address the server’s container reaches, INNGEST_* settings for projects and containers |
Supported (Inngest.Aspire.Hosting), for local development. The servers keep no state outside their container. |
step.waitForSignal and sending signals, step.fetch, step.ai.infer |
Supported (WaitForSignalAsync, SendSignalAsync, FetchAsync and AddInngestStepFetch, InferAsync) |
step.ai.wrap |
Supported (AIWrapAsync), with its input shown in the Inngest UI and an input edited there passed to the handler; an input edited for step.ai.infer is spread over its body, as in inngest-js |
| Realtime: publishing data and streams, subscription tokens, subscribing | Supported: PublishAsync and PublishStreamAsync (text/stream) on the step tools and IInngestRealtime, typed topics, tokens, SubscribeAsync over Inngest’s server-sent events with a new token on every reconnect, and MapInngestRealtime, a relay to browsers that shares one subscription per channel and topics |
| Realtime messages to SignalR clients: joining and leaving through a hub, authorization of each join, one shared subscription per channel and topics, a bounded queue for each connection, delivery by connection id | Supported (Inngest.SignalR) over the JSON and MessagePack protocols; under Native AOT only where SignalR supports it (JSON, .NET 9 and later) |
| Blazor components for realtime: Blazor Server, WebAssembly and interactive Auto | Supported (Inngest.Blazor): RealtimeView subscribes after its first interactive render and never while prerendering, through a source each side registers; the WebAssembly source reads the server-sent events of MapInngestRealtime with HttpClient response streaming and opens the connection again with a growing delay. It does not interpret run messages or pause in hidden tabs. |
| AWS Lambda: the serve endpoint behind API Gateway REST and HTTP APIs and function URLs, in a managed runtime function or a Native AOT executable | Supported (Inngest.AwsLambda, InngestLambdaHandler), with buffered responses only: Streaming has no effect, and the call’s cancellation token is never cancelled |
Streaming responses (INNGEST_STREAMING) |
Supported on the ASP.NET Core endpoint (InngestOptions.Streaming): a call longer than three seconds gets a 201, a space every three seconds and the wrapped result. Azure Functions streams the same way with the worker’s ASP.NET Core integration, and not with its built-in HTTP model. |
| Azure Functions (isolated worker): built-in HTTP model and ASP.NET Core integration | Supported (Inngest.AzureFunctions): tested against fakes of the worker and against the Inngest server behind a stand-in for the Functions host, not in a deployed app. A call has to be answered within the platform’s 230 seconds. |
| Receiving webhooks: Stripe, GitHub, Shopify, Standard Webhooks (Svix, Clerk, Resend) and HMAC signatures, event ids from the delivery ids | Supported (Inngest.Webhooks, MapInngestWebhook). Inngest’s hosted webhooks and their transforms are the counterpart. v1a (Ed25519) signatures are not checked. |
Event sessions and their propagation (meta.sessions, sessionPropagation) |
Supported (EventMeta, FunctionContext.Sessions, InngestOptions.SessionPropagation) |
The user behind an event: selected claims of the current user in the event’s user, protected, restored in the run, passed on to events, invocations and failure handlers |
Supported (.NET only): Inngest.UserContext, Inngest.UserContext.AspNetCore. See User context |
defer |
Not yet |
| Durable endpoints (beta in Inngest) | Supported on ASP.NET Core and marked experimental (INNGEST001): UseInngestDurableEndpoints, WithDurableEndpoint and [DurableEndpoint] in Inngest.AspNetCore. The steps of a request run inline, and the run goes to Inngest through the checkpoint API when a step has to wait or fails, or steps run in parallel. Not yet: the output proxy on the app’s domain, streaming responses from an endpoint, resuming an endpoint that requires authorization, and InngestTestHost. Flow control, defer and group.experiment are not supported for endpoints upstream |
Middleware transform hooks (transformFunctionInput, transformStepInput) |
Supported as IPayloadTransform, on raw JSON: events, memoized step results, step output and function output. The data an invoke sends is not transformed. |
wrapRequest |
Not implemented; ASP.NET Core middleware or an endpoint filter on MapInngest wraps a request |
group.parallel race mode |
Supported (ParallelAsync(ParallelMode.Race, ...), or StepOptions.ParallelMode for one step) |
| Fan-out over a list: a step per item with stable ids, a window of parallel items, fail-fast or collected outcomes, chunks in child runs for lists beyond the 1,000 steps of a run | Supported (.NET only): MapAsync, ForEachAsync, MapOutcomesAsync, MapInChunksAsync and MapOutcomesInChunksAsync over IStepTools, and FanOutFunction for the function of a chunk |
| REST API for runs: get a run, list the runs of an event, cancel a run | Supported (IInngestRunsClient) |
| OpenTelemetry spans and metrics | Supported (Inngest.OpenTelemetry) |
Encryption middleware (@inngest/middleware-encryption): default strategy, fallback keys, decrypt-only mode, the event field, custom services |
Supported (Inngest.Encryption), readable by the TypeScript package and the reverse. It reads the AES envelopes of the package’s first versions and never writes them, and it does not encrypt the data of step.invoke, which has no payload hook, nor the input of AI wrap steps and the request of inference steps, which no payload hook covers. |
| Sentry error reporting | Supported (Inngest.Sentry), without the TypeScript middleware’s Sentry spans |
| Chat clients of Microsoft.Extensions.AI as steps | Supported (.NET only, Inngest.Extensions.AI): UseInngestSteps makes model calls and streaming calls steps, UseInngestFunctionInvocation makes tool calls steps, with the input shown in the Inngest UI and an edited input passed on. Responses are saved as JSON written with AIJsonUtilities.DefaultOptions, so the app registers no contracts for them, and RawRepresentation does not survive a replay. A streaming call publishes its text to a realtime topic while the model runs, when asked to. InngestInferChatClient has Inngest call an OpenAI-compatible provider instead (step.ai.infer). |
| Transactional outbox: events saved with the app’s data in one transaction and sent afterwards under one id each | Supported (Inngest.EntityFrameworkCore) on Entity Framework Core 8 (net8.0) and 10 (net10.0), not 9; the tests run SQLite on both and PostgreSQL with EF Core 10 and Npgsql 10 on net10.0 only; not Native AOT |
| Unit tests on the real execution engine, with mocked steps | Supported (Inngest.Testing) |
End-to-end tests against the dev server or a self-hosted server in Docker, including the Connect gateway, and against an app under WebApplicationFactory |
Supported (Inngest.Testing.Containers, Inngest.Testing.Containers.AspNetCore), with the image you choose; tested with inngest/inngest:v1.49.0. The factory needs .NET 10. |
dotnet new templates for a web app and a Connect worker, and a tool that runs the Inngest dev server |
Supported (Inngest.Templates, dotnet-inngest) |
| Registering function classes without listing them | Supported (.NET only), when a project sets InngestGenerateFunctionRegistration: the source generator in Inngest writes AddInngestFunctions(). It does not compute the options at compile time |
Finding the types a Native AOT app has not given its JsonSerializerContext |
Supported (.NET only): an analyzer and a code fix in Inngest, for function classes, delegate functions and the typed steps; values whose type is known only at run time, such as the data of an invoke or an event the app sends, are not checked |
Spec deviations
Section titled “Spec deviations”| Topic | Spec | This SDK, following the server and inngest-js |
|---|---|---|
| Step not found | StepNotFound op |
500 with X-Inngest-No-Retry: false; the server has no such op |
| Bad signature | 500 |
401 |
| Sleep and wait payloads | opts.duration, opts.event |
sent both in opts and in name, which the server falls back to |
| Sync kind values | in-band |
in_band / out_of_band |
| Default retries | “4 attempts” | the server’s default of 4 retries (5 attempts) |
sdk field |
equal to X-Inngest-SDK |
dotnet:v<version> in the body, inngest-dotnet:v<version> in the header, as the server only splits the body value on : |
Differences from the TypeScript SDK
Section titled “Differences from the TypeScript SDK”Behaviour that differs on purpose, beyond the API shape:
| Area | TypeScript SDK | This SDK |
|---|---|---|
| Function options | Invalid options produce a warning at sync | Rejected at startup with one exception that lists every problem, each naming its function |
| Sync URL | Keeps deployId in the registered URLs |
Strips it, as the spec requires |
| Failure handlers | Always registered with runtime type http |
Same runtime type as the function they belong to |
| Event sends | Retries every failure | Retries transport errors, 5xx, 408, 425 and 429; other 4xx fail at once |
INNGEST_DEV URL |
Redirects the APIs even when isDev is false |
Only used in dev mode |
| Flags in variables | 1/true and 0/false; any other INNGEST_DEV is a dev server address |
Also on/yes and off/no, so INNGEST_DEV=off is cloud mode, not a dev server at http://off that turns off signature checks |
| Error names | JavaScript error names, with a __serialized marker |
.NET exception type names, no marker |
| Planned run steps | opts is {} |
opts.type is "step.run" |
| Targeted call whose step was started but not awaited | Answers 200 and drops the step | Runs the step |
Uncaught StepFailedException |
Final only for the most recent step failure | Always final |
Memoized {} |
Read as an error | Read as data |
| Steps called inside other steps or observer hooks | Hang | Throw InvalidOperationException |
| Invalid in-band sync body | 500 | 400 invalid_request |
| Introspection | Omits has_signing_key_fallback and authentication_succeeded when unauthenticated |
Includes both, as the spec describes |
In-band sdk_author |
inngest |
inngest-dotnet |
Match and If on a wait or cancellation |
Match replaces If (the types forbid setting both) | Both apply, joined with && |
| Connect shutdown | Closes the socket without a code | Closes with 1000 and WORKER_SHUTDOWN, which the gateway recognizes |
| Connect replies not acknowledged | Flushed over HTTP only on reconnect and close | Flushed as soon as the 5 second deadline passes, and at shutdown |
| Unsigned out-of-band syncs | Can be refused with INNGEST_ENABLE_UNAUTHED_SYNC=false |
Same, plus AllowUnsignedSync; unsigned callers are never told why a sync failed |
AWS Lambda
Section titled “AWS Lambda”InngestLambdaHandler serves the endpoint from a Lambda function. It reduces the two payload formats of API Gateway
(APIGatewayProxyRequest for REST APIs, APIGatewayHttpApiV2ProxyRequest for HTTP APIs and function URLs) to an
IServeRequest, hands it to InngestCommHandler with no service scope, so that the handler creates one per invocation,
and maps the ServeResponse back to the response type of the payload format. The framework name is aws-lambda, as
in inngest-js.
- Bodies. The signature covers the bytes Inngest sent: the UTF-8 of the body text, or what a base64 body decodes to
(
isBase64Encoded, which API Gateway sets when a binary media type matches). Responses are JSON, so they leave as UTF-8 text withisBase64Encodedfalse, and their signature, made over those bytes, holds. - Headers. Names are matched without regard to case. REST events merge
headersandmultiValueHeaders, with the multi-value form winning; HTTP API events have lowercase names and values joined by commas. - Query. HTTP APIs decode
queryStringParameterstwice, so a%2Barrives as a space.rawQueryStringis the source for HTTP APIs, and REST events, which only have decoded dictionaries, are encoded into a query string. One parser reads both, so a parameter has the same value from either. - URL. The scheme comes from the last value of
X-Forwarded-Proto, which the closest proxy appends and ASP.NET Core’s forwarded headers middleware trusts (httpsunless it sayshttp), and the host fromHost, or the request context’s domain name. The path israwPathfor HTTP APIs, which already starts with a named stage, andrequestContext.pathfor REST APIs when it extendspath, which is when it holds the stage. API Gateway fills it with the path the client called; on a custom domain both fields hold the base path mapping, as a real event in aws-lambda-dotnet issue 127 shows. inngest-js usespath, which leaves the stage out. The URL is built on first use, and only out-of-band syncs use it: an in-band sync names the URL in its signed body. Anything the event cannot show, such as the API mapping of an HTTP API or a CloudFront host, is left toServeOriginandServePath. - Time. The call’s cancellation token is
CancellationToken.None. Lambda gives no signal before it ends an invocation, and cancelling shortly beforeRemainingTimeruns out could only turn the 5xx that Inngest retries anyway into an error of the handler’s own: the serve handler rethrows a cancellation, so an error response would have to be built outside it, where the headers and the signature are made, calls that would have finished lose their last moments, and the abandoned work stays alive in an environment that a timeout would have reset. Checkpointing’sMaxRuntimelimits the time steps may start within. - Streaming. The answer is a single buffered response, which cannot write the 201 and the spaces of
InngestOptions.Streamingwhile a call runs, soStreamingis not consulted, and the handler logs a warning (event id 380) when it is built with the setting on. Lambda can stream responses to .NET functions (Amazon.Lambda.Core.ResponseStreamingwith function URLs inRESPONSE_STREAMmode, or REST APIs with a streaming integration), which would be a second response path with its own prelude; the handler does not use it. - Native AOT. Nothing reflects, and CI publishes
samples/Inngest.LambdaAotSample, the guide’s function, where a warning fails the publish. The event types come fromAmazon.Lambda.APIGatewayEvents, which the app lists in its ownJsonSerializerContextforSourceGeneratorLambdaJsonSerializer. The package ships none: a context fixes the shape of types it does not own, cannot hold the REST and HTTP API event types together (their nested types share names,SYSLIB1031) and could not be combined with the app’s other event types.
Connect
Section titled “Connect”With AddConnect, the app opens an outbound WebSocket to Inngest instead of exposing an endpoint, which suits
workers behind a firewall. The worker calls POST {api}/v0/connect/start with the hashed signing key, dials the
returned gateway with the v0.connect.inngest.com subprotocol and exchanges protobuf ConnectMessage frames
(connect.v1, copied from inngest-js):
- Handshake:
GATEWAY_HELLO, thenWORKER_CONNECTwith the same app configuration a sync sends (runtime typews), thenGATEWAY_CONNECTION_READYwith the heartbeat and lease intervals, thenWORKER_READY. - Each
GATEWAY_EXECUTOR_REQUESTis acknowledged, run through the same execution engine as an HTTP call, kept alive withWORKER_REQUEST_EXTEND_LEASEand answered withWORKER_REPLY. A reply that is not acknowledged within 5 seconds is sent again overPOST {api}/v0/connect/flush. GATEWAY_CLOSINGmakes the worker open a new connection before closing the old one; a lost connection is retried with backoff (1 s up to 5 minutes); a 401 switches to the fallback signing key.- On shutdown the worker sends
WORKER_PAUSE, waits for running calls up to the host’s shutdown timeout and closes with 1000WORKER_SHUTDOWN.
Each socket has a single writer fed by a channel and a reader that never runs user code, so a slow function cannot stall heartbeats or lease extensions.
Health check
Section titled “Health check”AddInngest on IHealthChecksBuilder (Inngest.AspNetCore) tells a readiness probe whether an app that serves
functions can work with Inngest; AddInngestConnect does the same for a Connect worker. It lives in
Inngest.AspNetCore because only apps that call MapInngest use it and that package already references ASP.NET
Core, where the health checks are, so Inngest gets no new dependency. It sends its requests through
InngestApiTransport, reached by InternalsVisibleTo, so they carry the SDK headers and the hashed signing key and are
repeated with the fallback key on 401 or 403, like every call to the API. SendKeyedAsync also says which key was
answered, which the result shows.
The request. In cloud mode, and against a self-hosted server, GET {api}/v2/apps?limit=1: it needs the key, lists
at most one app and changes nothing. GET /v2/health would be lighter, but Inngest Cloud answers it to anyone (an
unauthenticated request gets a 200), so it would pass with a key Inngest has never seen. A self-hosted server does check
the key there. In dev mode, GET {dev server}/health, the server’s own health endpoint, which does not depend on the
v2 REST API: /v2/apps came with Inngest v1.40.0 and /v2/apps/{id} with v1.27.1. A 2xx is healthy, 401 and 403 are a refused key, 429 is Degraded and
anything else is a failure. The body of that request is not read, and no error text of Inngest’s goes into a
description, so a gateway’s error page cannot reach one.
Sync. With CheckSync, after the first request succeeded, GET {api}/v2/apps/{app id}, which Inngest marks Beta:
data.functionCount, data.isArchived and data.latestSync (status, syncedAt, error, appVersion). The dev
server leaves out zero and false values and reports no status or time. The order of the verdicts: 404 is not
registered; archived; a status of error, or an error without a status; another app version than AppVersion, when
both are set; another number of functions than the registry holds; else synced. The registry’s count is the number of
definitions, which includes failure handlers, as Inngest counts them. The status, time and version that reach the result
are Inngest’s own text for the app, cut to 64 characters. Nothing is kept in the process about syncs: Inngest syncs
through whichever instance the load balancer picks.
Cost. The answer is kept per registration: CacheDuration for a healthy or rate limited one, and the smaller of
FailureCacheDuration and CacheDuration for a failure, so that an instance is not unready for long after a blip.
Probes wait on one gate, and each takes the answer when it gets through, so overlapping probes send one request.
The health check service builds the check for every run, in a scope of its own, so the answer lives in the registration,
not in the check.
Cancellation. The request ends with the probe’s token and at 90 percent of the registration’s timeout, so that the
check can say Inngest was too slow with the registration’s failure status before the health check service gives up on it
with its own words and Unhealthy. A registration has no timeout unless it is given one, so then the request ends after
10 seconds; HttpClient would wait for 100. A cancelled probe stores nothing, and a timeout is stored as a failure. One
deadline covers both requests of a check.
What the result holds. signingKeyFallback, signingKey and eventKey are set, not set or missing, never
values. The address is shown as scheme, host and port. The tests look for the keys, the hashed token and the event
key in the description, the data and the log of every kind of answer.
Test containers
Section titled “Test containers”Inngest.Testing.Containers follows the module pattern of testcontainers-dotnet 4.16: InngestServerBuilder derives
from ContainerBuilder, InngestServerConfiguration from ContainerConfiguration, InngestServerContainer from
DockerContainer, and the builder takes the image, as every module has since 4.10, so there is no default image to go
stale. The types are named after the server because InngestBuilder is one letter from the SDK’s IInngestBuilder,
which test code uses beside them. The builder keeps what the caller chose (the mode, the keys, the app URLs, the
gateway) in the configuration and turns it into the command line in Build, so the order of its methods never matters
and one builder can build several containers, each with keys, a gateway port and a connection string provider of its
own: a provider remembers the container it was started with, so a shared one answers for the last. Arguments added with
WithCommand come last, and a wait strategy with conditions of its own, which WithWaitStrategy replaces the default
with, is kept.
- Command.
inngest dev --no-discovery --no-poll, orinngest start --signing-key K --event-key E, then the timing flags (--poll-interval 1 --retry-interval 1 --tick 50),--connect-gateway-portand a-ufor each app. Theinngest startsigning key must be plain hexadecimal digits in pairs. With-uthe server retries a failed sync at the poll interval and stops once it succeeds; it does not poll the app afterwards, and nothing tells the test when the app is registered. - Reaching the host. Inside the container localhost is the container, so a loopback app URL becomes
host.docker.internal, which the builder maps tohost-gatewayfor Linux. Any other address is left as it is. The app must listen on all interfaces, since on Linux the container comes in over the Docker bridge, not loopback. - Ports. The API is published on a random host port. The Connect gateway is published on the same port inside and
outside, because the start API hands workers the port as the container knows it, so the port has to be chosen before
the container exists. A port the builder picks is held by a listening socket from
Builduntil the container starts, because pulling the image can take long and the port may be taken in the meantime. The container lets it go just before Docker starts the container, or when it is disposed. Starting again with a fresh port when Docker still finds the port taken is not possible on the same container, asDockerContainerkeeps its configuration to itself and a created container cannot be created again; the caller builds another. The server binds its gRPC ports (50052 and 50053) and the gateway only after it has opened connections, and the kernel may give such a port to one of them as a source port, so the container reserves them withnet.ipv4.ip_local_reserved_ports. - Syncing after the start. A
PUTto the app’s endpoint makes an SDK register itself with the server, under the URL the request arrived on.SyncAsyncsends it with the container’s name for the app as theHostheader, so neither the app nor the server needs the other’s address before it starts and no port is picked up front. It also sendsX-Inngest-Server-Kind(devorcloud), which the SDK hands to the server it registers with, as with the server’s own sync. For the self-hosted server the request carriesX-Inngest-Signature, a MAC of the (empty) body and the timestamp, which keeps the app from logging an unsigned sync. The GraphQLcreateAppmutation of the dev UI also has the server sync an app, and was not used: againstinngest startthe sync it makes arrives unsigned, a failed one stays in the app list, and the error is a bare code such asunreachableorurl_not_found. - Variables.
GetSdkEnvironmentis for processes that inherit their parent’s environment, so it sets every variable that could outrank the others. The SDK reads the API address fromINNGEST_API_BASE_URL, thenINNGEST_BASE_URL, then the dev server’s address, the event API address fromINNGEST_EVENT_API_BASE_URLfirst, andINNGEST_DEVpicks the mode, where only an explicit0keeps an inheritedINNGEST_DEV=1from switching the signature checks off. Variables the list leaves out, such asINNGEST_SERVE_ORIGIN, still apply, and the guide says so. - WebApplicationFactory. The server needs a real socket, which
TestServerdoes not have. .NET 10 addedUseKestrel; earlier versions can only get one with a second host built beside the in-memory one, which runs hosted services twice and splits the app’s state, soInngestWebApplicationFactorytargets net10.0 and lives in a package of its own:Microsoft.AspNetCore.Mvc.Testingbrings build targets and a version that must not be forced on users of the container module. The factory callsUseKestrelwithListenAnyIP(0). It has to be called on the factory that starts, because a factory made byWithWebHostBuilderdoes not inherit it. The options are applied withPostConfigurefromCreateHost, so the container wins whatever the app or an override ofConfigureWebHostset. Kestrel reports an app on all interfaces as[::], which the factory takes for its clients’ base address and which a client cannot connect to on Windows.CreateDefaultClientis fixed inConfigureClient, butCreateClientandCreateDefaultClient(Uri, ...)set the base address after it, from options that default tohttp://localhost, port 80, so the factory declaresCreateClientagain. The addresses collection of a started Kestrel cannot be changed, so there is no earlier place to fix it.
Templates and the dotnet-inngest tool
Section titled “Templates and the dotnet-inngest tool”The template package. src/Inngest.Templates has PackageType Template and no build output. Each template under
templates/ is a real project with !!INNGEST_VERSION!! where the version of an Inngest package goes. At pack time the
target StageTemplates, which runs before the package files are collected and so also for dotnet pack --no-build,
copies the folder to obj, replaces the tokens with $(PackageVersion) in a small inline MSBuild task and packs the
copy. It fails when a token is left, and it copies on every pack, because a new version changes the result while the
files stay the same. The worker’s Microsoft.Extensions.Hosting takes its version from Directory.Packages.props the
same way. This follows the Aspire templates, which replace !!REPLACE_WITH_LATEST_VERSION!! in a copy at pack time. The
ASP.NET Core templates generate their project files with a task of the Arcade SDK, which a project outside that repository
does not have, and Aspire runs a C# script with dotnet. An inline MSBuild task needs neither.
The template.json files use what dotnet new offers instead of tokens of their own: a Framework choice that replaces
net10.0, the port generator with coalesce for --http-port, the kebabCase form of the project name for the app
id, a fresh UserSecretsId, and the restore post action. The generated code has no namespace and no folders, as in the
getting started guide. Microsoft.TemplateEngine.Authoring.Tasks only localizes templates, and
Microsoft.TemplateEngine.Authoring.TemplateVerifier snapshots a template folder, so neither is used.
Their tests. tests/Inngest.Templates.Tests packs Inngest, Inngest.AspNetCore, Inngest.Connect and
Inngest.Templates into a temporary feed with a version of its own (a version restored from a feed stays in the global
packages folder, so a fixed one would hide changes), installs the template package into a throwaway hive
(--debug:custom-hive), makes both templates and builds them with a nuget.config that maps Inngest* to the feed and
the rest to nuget.org. It also starts the web app and reads its introspection, which proves the launch profile, the
Development settings and dev mode work together. The commands run in a folder outside the repository, where the newest SDK
builds everything, so the net8.0 worker gets a global.json that selects the .NET 8 SDK; the test is skipped, with a
reason, on a machine that has none, and CI installs both. That build turns off CheckEolTargetFramework, since warnings
are errors in it and the SDK warns about a target framework out of support. A run packs four projects and builds two, so it
is a job of its own in CI.
Each of the two packages has a README of its own, src/Inngest.Templates/README.md and src/dotnet-inngest/README.md,
packed in place of the shared src/README.md, which describes the SDK. The templates project also leaves bin and obj
out of what it packs, for a template that was built in place.
The tool. src/dotnet-inngest is a PackAsTool project named dotnet-inngest, so that dotnet inngest finds it, as
dotnet ef finds dotnet-ef. It targets net8.0 and net10.0, each in its own folder of the package, with
RollForward=Major for a machine that has neither. The arguments are parsed by hand: the tool owns three options and
passes the rest to inngest dev, whose options change with the release, which is the opposite of what a command line
library is for, and the tool stays free of dependencies. Its types are internal, tested through InternalsVisibleTo.
- Download. The asset is named
inngest_<version>_<os>_<arch>.<tar.gz|zip>in the release of the tag, as the install script and the npm package expect it, chosen by the architecture of the machine. The pinned version is checked againstPinnedChecksums, a copy of the release’schecksums.txtin the source, and never against the release, so a file replaced on GitHub together with its checksums is caught. Another version is checked against itschecksums.txt, which catches damage only. The pin, theinngest/inngestimage in the docs and the integration tests are raised together. - Cache.
<local app data>/inngest-dotnet/cli/<version>/<os>_<arch>/inngest. The download and the unpacked binary are made in a staging folder beside it, which is moved into place in one step, so an interrupted install leaves nothing that looks installed and two installs at once end with one. Only the binary is unpacked, to a path the tool chooses, whatever the archive’s entries are called, and it gets the execute bit. It is flushed to disk before the rename, so a crash cannot leave a file without content that the next run finds. The checks happen at install time only, and a binary in the cache is run as it is, so the folder must not be writable by other users; the docs say so. - Running. The CLI inherits the console, so its output and input are the user’s. Ctrl+C reaches the CLI as it does
any foreground program, and the tool waits for it and returns its exit code. On Unix
SIGINTandSIGTERMthat reach the tool alone are passed on withkill; the CLI takes the secondSIGINTof a terminal as one.SIGKILLand a crash cannot be passed on, and on Windows nothing is. There is no job object, which could not be tested, so a tool that is ended alone leaves the CLI running. - Downloads. A download is limited by how long the server may send nothing, 60 seconds for the headers and between two
reads of the body, and not by its total time. With
ResponseHeadersReadtheTimeoutof anHttpClientends at the headers, so it would leave a body that stalls waiting forever. The tool sets it to infinite and limits the reads itself. A limit that fires, which the user did not ask for, is an error with the Docker command. Only Ctrl+C ends with 130. - Address. Without
-uor--no-discovery, the first launch profile that runs the project and has anhttpaddress gives the app’s URL, plus/api/inngest. - Docker. The failures that mean the CLI cannot be had print the
docker runcommand for the same address. Nothing is run for the user.
Transactional outbox
Section titled “Transactional outbox”Inngest.EntityFrameworkCore makes sending an event part of a database transaction. The app adds events to its
DbContext with AddInngestEvent, SaveChanges stores them with its data, and a hosted dispatcher per context type sends them
afterwards. Delivery is at least once. What is meant to make a resend harmless is that every attempt carries the row’s event id,
which Inngest is documented to drop when it repeats within 24 hours; see Event ids below for what was checked.
- Table.
InngestOutboxMessage, mapped byModelBuilder.AddInngestOutbox()asInngestOutbox. The key is the event id, a ULID unless the code gives one, so a second row for the same fact fails the save. The id, the name and the version are limited to 255 characters, and a case-insensitive collation (the default of SQL Server and MySQL) makes ids that differ only in case the same key.Data,UserandSessionsare the event’s JSON as text, written when the event is added; times are UTCDateTimes with a converter that makes the kind UTC, because providers return them unspecified.Timestampis the event’sts, which the caller may set to any time, soCreatedAtrecords when the row was added. One index on (SentAt,NextAttemptAt) serves the claim and the cleanup. - Adding.
AddInngestEventmakes the checks the client makes when it sends (name, data and user as JSON objects, sessions) and serializes through theJsonTypeInfoofInngestSettings.SerializerOptions, so a failure is found while the code that added the event is on the stack and trimmed apps use the metadata they already registered forSendAsync. It finds the writer in the application service provider thatAddDbContext,AddDbContextPoolandAddDbContextFactoryleave in the context’s options. The event keeps the time it was added as itsts, not the time the dispatcher sends it.PropagatedSessionsis refused, because it only exists for events sent from a run, and an event added inside a function does not inherit the run’s sessions, because the dispatcher sends it outside the run. - Waking. One interceptor instance serves every context and needs no service provider at configuration time: it finds the
signal through the application services of the context that saved, so
UseInngestOutbox()works in the callbacks that have no provider and inOnConfiguring. When the dispatcher starts it says once if the context’s options lack the interceptor.AddInngestEventmarks the context;SavedChangeswakes the dispatcher at once when no transaction of the code’s own is open, and otherwise leaves the wake-up toTransactionCommitted, so the dispatcher is not woken to find nothing. A rollback clears the mark. The signal is a one-slot channel per context type, so a wake-up that comes while the dispatcher is busy is kept, and many count as one. With aTransactionScopeEF sees no transaction of its own, so the wake comes at the save, before the scope completes, and the events leave at the next poll (checked on PostgreSQL; SQLite has no ambient transactions). The poll interval is the fallback for everything else, including saves by other processes. - Claiming. Dispatchers take no lock. A claim selects the ids of the due rows (
SentAtnull,NextAttemptAtpassed, no live lease), thenExecuteUpdates those ids with a token and an expiry, repeating the test for “due” in theWHEREof the update, and reads back the rows that hold the token, by key as well, because the token has no index and the table holds a day of sent rows. The two statements matter: on PostgreSQL, anUPDATE ... WHERE id IN (SELECT ... LIMIT n)that waited for a row another one was updating took the row as well and overwrote the token (checked with two sessions), where the repeated test in the outerWHEREmade the loser update nothing. A dispatcher that died holds its rows until the lease ends. Marking sent or failed names the token, so a dispatcher that lost its lease changes nothing. - Sending. One request per claimed batch, each event with its own
id, throughIInngestClient, so the client’s retries and the middleware apply. A request Inngest refuses because of its events (400, 409, 412, 413, 422, or an event the client refuses to write) is repeated event by event, so one bad event does not hold back the others; other failures fail the batch together. The first of those requests is sent whatever is left of the lease, and the others only while the lease covers another request, so every pass decides at least one event; the options require a lease of at least twiceSendTimeoutfor the same reason. A failed event is retried afterRetryDelay, doubling per failure up toMaxRetryDelay, and is never given up; each refused one is logged at error level on every attempt, with its id and name. When the host stops, claimed events are released without an attempt. - Clocks. Leases, retries and the due test compare an instance’s clock with times other instances wrote, because the
clock is the
TimeProvider‘s and not the database’s. A dispatcher takes rows another is still sending only if its clock is ahead by more thanLeaseDurationminusSendTimeout; a row saved by an instance whose clock is ahead waits for the others’ clocks, unless that instance dispatches it itself. - Cleanup. Sent rows older than
Retention(a day, as long as Inngest remembers ids) are deleted withExecuteDeletein batches, on a loop of its own so that a long cleanup never delays a send. - SQL Server. Under the locking
READ COMMITTEDthat is its on-premises default, the due query waits for rows that open transactions inserted, so one long unit of work that adds an event stalls every dispatcher until it commits; the remedy isREAD_COMMITTED_SNAPSHOT, on by default in Azure SQL Database. This follows from how SQL Server locks and was not run. PostgreSQL does not wait (a test holds an uncommitted event while a dispatcher runs). - Event ids. Inngest takes any string as an event id. The dev server (v1.49.0) starts a function’s run once per id: a
second send under the same id is answered 200 with a new internal event id, and no run starts. The server’s source, read at the
v1.49.0 tag (28cb533), says that a repeated event can exist in its event store, because the id is enforced when scheduling
runs, and keeps the run’s idempotency key for 24 hours (
FunctionIdempotencyPeriod), which is what Inngest’s documentation gives for Inngest Cloud. Only a repeat within seconds was tested, and only against the dev server.
How the established outboxes differ, where it shaped this one (read at CAP ad19c50, MassTransit d9f61f7 and Wolverine e9110b7,
all of October 2026). CAP stores messages in the app’s transaction through its own transaction wrapper and, after the commit,
pushes them into an in-process queue; its retry processor polls for failed rows and serializes instances with a lock row in a
table that expires (UseStorageLock), and its cleanup deletes expired rows in batches of 1000. MassTransit locks one outbox
state row per unit of work with provider-specific statements (FOR UPDATE SKIP LOCKED, UPDLOCK, READPAST), sends in
sequence order inside that transaction and deletes the rows it sent; its commit signal is a cancelled delay when the scope ends
after the entity left the Added state. Wolverine stores envelopes with an owner node id, sends from memory after the commit,
and lets a durability agent reassign the rows of nodes that left the cluster. This package takes no cluster membership and no
provider-specific SQL: a lease column set by ExecuteUpdate is the only coordination, which suits Inngest because it does not
order events and drops a repeated id.
EF Core versions. ExecuteUpdate and ExecuteDelete are in Microsoft.EntityFrameworkCore.Relational in EF Core 8 and in
Microsoft.EntityFrameworkCore from EF Core 9 on, so code compiled against one does not run on the other (a
MissingMethodException). The net8.0 build uses EF Core 8 and the net10.0 build EF Core 10, selected in
Directory.Packages.props; an app on .NET 9 gets the net8.0 build, so EF Core 9 is not supported, and the dispatcher refuses to
start when it finds EF Core 9 loaded, naming the reason. There is no net9.0 target, because the SDK has none. EF Core is not
trim or Native AOT compatible (its DbContext constructors are marked RequiresUnreferencedCode and RequiresDynamicCode), so
the project sets IsAotCompatible to false while keeping the analyzers on; its own code has no warning.
Tests. tests/Inngest.EntityFrameworkCore.Tests runs against SQLite on both runtimes (the net8.0 build on EF Core 8, in a
container when the machine has no .NET 8 runtime). tests/Inngest.IntegrationTests/Outbox runs a dispatcher against PostgreSQL
with Testcontainers, on net10.0 only, so with EF Core 10 and Npgsql 10: four dispatchers at once, the order of claims that would
overwrite a token if the update did not repeat the test for “due”, commit visibility and a TransactionScope. Against the dev
server, an event saved through the outbox starts its function, and a resend under the same id starts nothing.
Aspire
Section titled “Aspire”Inngest.Aspire.Hosting puts the Inngest server into an Aspire AppHost. The shape follows the container integrations
of Aspire (Seq, RabbitMQ) and of the Community Toolkit (MailPit, Ollama): a ContainerResource, an Add*
extension, an internal class that holds the image tag, health checks and a link for the UI. These decisions are
specific to Inngest:
- The container. The image is
inngest/inngest, at the tag the integration tests of this repository use, and has no entrypoint, so the resource setsinngestas the entrypoint. The dev server runsdev --no-discovery: discovery scans ports inside the container, where the apps are not. Polling stays on, at the server’s default of 5 seconds, so that an app that starts after the server, or restarts with other functions, is found; the self-hosted server polls only with--poll-interval, so it gets one. - Addresses. Nothing is written as text.
WithAppandWithReferenceputReferenceExpressions over endpoint references into the arguments (-u <address>, once per app) and the environment, and Aspire resolves them for the resource that receives them. For a server’s container, an app that is a project on the host isaspire.dev.internalwith the port of the container tunnel (orhost.docker.internalwhere the tunnel is off); for a project on the host the server islocalhostwith the proxy’s port; for a container it is the name on the container network. The SDK registers the address it was synced through, so the step calls go to the same address. - The Connect gateway.
POST /v0/connect/startanswers a worker with the host it called and the port the gateway listens on in the container (8289), which is not the port Aspire publishes it on, so a worker that used the answer would logCould not open a WebSocket to the gateway at ws://localhost:8289/v0/connect. Publishing the gateway on the same port is not an option: Aspire picks only the host port of a container endpoint and wants the container’s port in the model. So the gateway is an ordinary proxied endpoint, andWithReferencegives the worker the address of that endpoint asINNGEST_CONNECT_GATEWAY_URL, which the Connect worker prefers to the server’s answer (ConnectOptions.GatewayUrl). A worker that is started outsideWithReferencegets the server’s answer. The tests start a Connect worker against both servers. - Dev and self-hosted. The dev server’s reference is
INNGEST_DEV=<address>, assamples/Inngest.Sample/compose.yamlhas it for dev mode. The self-hosted server’s reference isINNGEST_DEV=0and the two keys, as thecloudprofile of that file has it;0is set because projects inherit the environment of the AppHost, and an inheritedINNGEST_DEV=1would turn off signature checks. Both also get the address inINNGEST_BASE_URL,INNGEST_API_BASE_URLandINNGEST_EVENT_API_BASE_URL:InngestSettingsreads the last two before the base URL, so an inherited one would win over it. OtherINNGEST_*variables of the AppHost’s environment still reach the projects, and anINNGEST_SERVE_ORIGINmakes runs undeliverable without a word, which the guide says. The keys reach the server throughINNGEST_SIGNING_KEYandINNGEST_EVENT_KEY, which its CLI reads (flag, then variable, then file), so they are not in the command line.inngest starttakes a signing key only as hex of even length, so the generated one is 64 digits, and a key that is not is reported when the server starts. - The manifest. The dev server is excluded (
ExcludeFromManifest), andWithReferenceto it sets nothing in publish mode: a reference would dangle, andINNGEST_DEVwould put a deployed app into dev mode, which checks no signatures. The self-hosted server is acontainer.v0with its keys as parameters, and what refers to it has settings for cloud mode.ManifestTestswrites the manifest through the publish mode of an AppHost and checks that every{resource.…}expression names a resource in it. - Tests.
tests/Inngest.Aspire.Hosting.Testsis itself an AppHost project:Aspire.Hosting.Testingfinds the orchestrator and the dashboard through assembly attributes thatAspire.AppHost.Sdkwrites, and has no other way to find them. TheAppModeltests resolve arguments and environments for endpoints that the test allocates.PinnedVersionsTestsfails when the Aspire version ofglobal.json(the AppHost SDK) differs from the packages inDirectory.Packages.props, or when the default image differs from the one that the harness of the integration tests runs. TheContainerstests run both servers in Docker next to a project and a Connect worker, through the real orchestrator, with an inheritedINNGEST_*environment that the settings ofWithReferencemust win over.
User context
Section titled “User context”Inngest.UserContext carries the user who caused a run into it. It is a format and a middleware; Inngest itself only
lets it see its internals. It has no counterpart in the TypeScript SDK.
- Wire. The event’s
userobject, which Inngest documents as user data and stores encrypted at rest, gets one reserved key,_inngest_dotnet_user: {"v":1,"t":"<text>"}; every other key is the app’s.tis whatIUserContextProtector.Protectmade of this JSON:{"v":1,"iat":<unix s>,"exp":<unix s>,"ev":"<event name>","nt":..,"rt":..,"c":{"<claim type>":["<value>"]}}.evties the text to the event it was made for, andfnfor an invocation to the function that is invoked (<app id>-<function id>), becauseinngest/function.invokedis one name for every function and Inngest builds it from a body its caller supplies.ntandrtare the name and role claim types when they are not the .NET defaults, andcholds only the claim types ofUserContextOptions.Claims, up toMaxSizebytes. The expiry and the event name are inside the protected text, so every protector has them and a custom one only has to detect changes and, ideally, encrypt. The default protector is Data Protection with the purposesInngest.UserContextandv1. - Capture.
WrapSendEventAsyncruns for every send. Outside a run it asks theIUserContextSource; the ASP.NET Core package registers one overIHttpContextAccessor, which flows with the async context into the scope the client makes for middleware. Inside a run (context, orInvocation.AmbientContextfor a client used in a function) it attaches the run’s own snapshot and never asks the source, because the request that reached the app is Inngest’s. Each event gets a text made for its own name, and the snapshot keeps its originaliatandexp, so passing it on never extends it. An event or invocation whose run would start afterexpgets none, and an information line says so. Invocations have no middleware payload hook, soInvokeOptions.WithUserputs the envelope inInvokeOptions.User. - Restore.
WrapFunctionAsyncreadsFunctionContext.RawEvent, or for the failure handler of a function (FunctionDefinition.FailureHandlerFor) the event insideinngest/function.failed, and keeps the snapshot in aConditionalWeakTablekeyed by the run’sFunctionContext.ICurrentUseris a singleton that finds the running run throughInvocation.AmbientContexton every read, so it is the same in every DI scope of the call, including the sibling scope a scoped step makes, and in singletons. A function that batches events gets no user: a batch has no single one. - Validity. A function replays from its top on every call, so a user that expired halfway through a run would change
under it. The expiry is compared with the later of the creation time of the run, which its id holds (a ULID that
Inngest makes and every call shares), and the
tsof its event (in the failed run’s event for a failure handler), not with the clock of the call. Against the dev server, the run of an event with a future timestamp is created when the event is received but starts at the timestamp, so the creation time alone would let a payload copied from an event act as its user years after it expired. A run held back by a queue after it was created keeps its user. An id that is not a ULID falls back to the clock of the call, with a warning. - Failure. A payload that cannot be used gives an anonymous principal and one warning, written once per run through
the replay-safe logger, that names the reason and the event and never the payload.
OnInvalid = FailRunfails the run instead, except a failure handler: without retries, except for text that could not be unprotected, which is retried. Data Protection lets theCryptographicExceptionof a payload that is not valid through as it is and wraps every other failure, such as a key store that cannot be read, in one with anInnerException. The default protector therefore throws for the second kind, so that the call fails and is retried instead of running as nobody, and answers false for the first.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.