Skip to content

Webhooks

Services tell your app what happened by sending it an HTTP request: a payment succeeded, a pull request was opened, a user signed up. MapInngestWebhook turns those requests into Inngest events. It reads the raw body, verifies the sender’s signature before it parses anything, makes an event whose id is the provider’s id for the delivery, sends it and answers at once. A function does the work, with all the retries, steps and flow control of any other.

The signature schemes are written out with System.Security.Cryptography and need no provider SDK: Stripe, GitHub, Shopify, and any provider that follows Standard Webhooks, such as Svix, Clerk and Resend. Other providers that sign with an HMAC are a few lines of options, and anything else is an interface to implement.

Map one endpoint for each provider, with the secret the provider gave you for it:

app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(configuration["Stripe:WebhookSecret"]!));
app.MapInngestWebhook("/webhooks/github", WebhookProvider.GitHub(configuration["GitHub:WebhookSecret"]!));
app.MapInngestWebhook("/webhooks/shopify", WebhookProvider.Shopify(configuration["Shopify:ClientSecret"]!));
app.MapInngestWebhook("/webhooks/clerk", WebhookProvider.StandardWebhooks("clerk", configuration["Clerk:WebhookSecret"]!));
app.MapInngestWebhook("/webhooks/resend", WebhookProvider.StandardWebhooks("resend", configuration["Resend:WebhookSecret"]!));

Give the provider the URL of the endpoint, over HTTPS. AddInngest must have been called, because the endpoint sends through the app’s IInngestClient. The endpoint allows anonymous callers, since a webhook proves who sent it with its signature rather than with a login, so an app-wide authorization policy does not turn providers away.

A function listens for the events by name, as for any event. This one starts when Stripe says a checkout was paid:

using System.Text.Json;
public sealed record StripeEvent(string Id, string Type, StripeEventData Data);
public sealed record StripeEventData(JsonElement Object);
using Inngest;
[InngestFunction("fulfil-order")]
[EventTrigger("stripe/checkout.session.completed")]
public sealed class FulfilOrder : InngestFunction<StripeEvent>
{
public override async Task<object?> ExecuteAsync(FunctionContext<StripeEvent> context, CancellationToken cancellationToken)
{
var session = context.Event.Data.Data.Object;
var sessionId = session.GetProperty("id").GetString()!;
await context.Step.RunAsync("fulfil", () => Task.CompletedTask);
return sessionId;
}
}

Providers wait only seconds for an answer, so the endpoint does no more than receive. Whatever takes time belongs in the function.

  1. The body is read as raw bytes, up to WebhookOptions.MaxBodySize (1 MiB by default). A larger one is answered with 413 without being read to its end.
  2. The signature is verified against the raw body, and the signed timestamp, if the provider signs one, against the clock. A request that fails is answered with 401, and nothing else about it is looked at.
  3. The body is parsed as JSON and made into an event by the provider’s conventions, below. A body that is not JSON, or that no event can be made of, is answered with 400.
  4. Your Transform, if you set one, changes the event or drops the delivery.
  5. The event is sent to Inngest, and the request is answered once Inngest has accepted it. Nothing waits for a run.
Answer When
200 The event was sent.
204 Transform returned null for the delivery.
400 The signature is valid, but the body is not JSON, or no event can be made of it (a delivery id, an event name or a type is missing or malformed), or the event can never be sent: Inngest answers it with a client error, the client refuses it (a name with the inngest/ prefix, which Inngest reserves, or data that is not an object), or its payload cannot be written as JSON (a string with an unpaired surrogate such as "\ud83d" parses but cannot be written).
401 The signature, or a header it needs, is missing, malformed or wrong, or the signed timestamp is outside the tolerance.
413 The body is larger than MaxBodySize, or Inngest answers that the event is too large.
503 Inngest did not accept the event within SendTimeout, four seconds by default, or failed in a way that may pass: no connection, a 5xx, or 408, 425 or 429.
500 The verifier or the Transform threw.

No answer has a body, so a caller who cannot prove who it is learns nothing from them. Anything but a 2xx is a failed delivery to the provider. Most providers send the webhook again on their own schedule when they get one; GitHub does not. Because the event id is the provider’s id for the delivery, a delivery that reached Inngest after all is dropped the second time. The log says why a request was rejected, in words of the SDK’s own and never with a secret, a signature or a body.

A failure that cannot pass is not left to look like one that can. An event Inngest or its client will never accept is answered with a client error and logged as an error that says sending the webhook again fails the same way, so that neither the provider nor you wait for a retry that cannot work. Only a failure that may pass, an unreachable Inngest or a 5xx, is a 503.

Provider Factory Name Data Id
Stripe WebhookProvider.Stripe(secret) stripe/ and the event’s type, such as stripe/payment_intent.succeeded The whole Stripe event The event’s id, evt_...
GitHub WebhookProvider.GitHub(secret) github/ and X-GitHub-Event, such as github/pull_request The payload X-GitHub-Delivery
Shopify WebhookProvider.Shopify(secret) shopify/ and X-Shopify-Topic with the slash as a dot, such as shopify/orders.create The payload X-Shopify-Webhook-Id
Standard Webhooks, Svix, Clerk, Resend WebhookProvider.StandardWebhooks(prefix, secret) The prefix you choose and the payload’s type, such as clerk/user.created The payload’s data, or the payload when it has none webhook-id, or svix-id
Other HMAC WebhookProvider.Hmac(name, options, secret) The name you give, for every event The payload The header you name, if any

These follow the transforms in the Inngest guide to webhooks, where it shows one: stripe/<type> with the event as the data and its id as the id, and clerk/<type> and resend/<type> with the data of the payload. Its GitHub example names events github.<event> with a dot, where this uses a slash like the others. Rename them with a Transform if functions already trigger on the dot.

The data is the payload exactly as the provider sent it, so a record for it has the provider’s own property names, and it is only ever what the signature covers: nothing from a header is added to it. The payload must be a JSON object, as the data of every Inngest event is. A provider that has delivery ids must have sent one: a GitHub or Shopify delivery without its id header, or a Stripe event without an id, is answered with 400, because an event without an id would never be deduplicated. A name or an id taken from a request is at most 256 characters of visible ASCII, and a whole event name is at most 256, so a header that is not a short token never becomes the name or the id of an event.

Where the name and the id come from matters for what can be trusted. Stripe and Standard Webhooks sign them: the type and the event id are in the signed body, and webhook-id is part of the signed text. GitHub and Shopify sign only the body, so the event name and the id of a delivery travel in headers that nothing signs, and so does the shop domain that Shopify sends. The name and the id are checked for their shape and used, but whoever holds one captured delivery can change them, as Replays says.

Transform receives the delivery and the event the provider’s conventions made, and returns the event to send. Return null to drop a delivery, which is answered with 204. This ignores the Stripe events nothing listens to, and moves the rest under another name:

app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(secret), new WebhookOptions
{
Transform = (delivery, @event) =>
{
if (!@event.Name.StartsWith("stripe/invoice.", StringComparison.Ordinal))
{
return null;
}
return @event with { Name = "billing/" + @event.Name["stripe/".Length..] };
},
});

The delivery has its parsed Payload as a JsonElement, its DeliveryId, any request header through GetHeader, and the request’s Services. The Transform runs on the request, after the signature has been verified, so keep it quick and let a function do the work. If it throws, the request is answered with 500 and a provider that retries tries again. Headers are not signed by most providers, so data taken from one is not proof of anything.

The delivery, and its Payload, are valid only while the request is handled, because the payload is read in place from the body, which goes back to a pool afterwards. The event is not tied to them: a JsonElement as the Data or the User of the event that comes out of the Transform is copied before the event is sent, so an event that something keeps, such as InngestTestHost.SentEvents or a middleware, can be read after the request. A JsonElement of the payload inside another object is not copied; call Payload.Clone() when you put one there.

The data of a Shopify event is the payload, which does not say which shop sent it. An app installed on many shops can add the header in a Transform. That header is not signed, so it is a claim of whoever sent the request, and whoever holds a captured delivery can send it again with another shop in it. Prefer a shop that the payload itself names where there is one.

using System.Text.Json.Nodes;
app.MapInngestWebhook("/webhooks/shopify", WebhookProvider.Shopify(secret), new WebhookOptions
{
Transform = (delivery, @event) =>
{
// A copy of the payload as an object, with one property more.
var data = JsonNode.Parse(delivery.Payload.GetRawText())!.AsObject();
data["shop_domain"] = delivery.GetHeader("X-Shopify-Shop-Domain");
return @event with { Data = data };
},
});

A trimmed or Native AOT app adds JsonObject to its JsonSerializerContext with [JsonSerializable(typeof(JsonObject))], as for any type it sends.

Providers send a webhook again when they get no 2xx, and some send the same one twice for no reason. Each delivery carries an id that is the same for every attempt, and the event takes it as its id. Inngest keeps the first event with an id and starts no runs for the later ones, for 24 hours; the later ones show in the event history. So a webhook that reached Inngest once and is delivered again starts one run, and one that was answered with a 5xx starts its run on the attempt that gets through.

What this does not cover:

  • A delivery without its id. For a provider that has delivery ids, a delivery that comes without its id is answered with 400 and is not sent: an event without an id would never be deduplicated.
  • Different ids for one thing. Stripe says that in some cases two separate events are sent for what happened once. Their ids differ, so both start runs. Its advice is to identify them by the id of the object in data.object together with the event type, which is what a function’s Idempotency key can be made of.
  • The same id for several events. GitHub’s API describes the id in X-GitHub-Delivery as the identifier of the event, shared by the deliveries of all the webhooks that subscribe to it. A redelivery has it, and so do two webhooks of yours that GitHub sends the same event to. Shopify’s X-Shopify-Webhook-Id is different for each subscription, and its X-Shopify-Event-Id is the one they share. If two endpoints send to the same Inngest environment and must both start runs, make the ids differ in a Transform, such as @event with { Id = delivery.GetHeader("X-GitHub-Hook-ID") + ":" + @event.Id }.
  • A manual redelivery. GitHub does not retry on its own. Redelivering from its page or its API, or resending an event from the Stripe dashboard or CLI, sends the same id again, which Inngest drops for 24 hours if the first attempt got through. To run the work again, rerun the function from the Inngest dashboard.
  • Features that ignore event ids. Inngest documents that event idempotency does not apply to debouncing, to event batching and to a paused function. A function that uses them needs an idempotency key of its own.
  • Ids after 24 hours. The same id is a new event after that.

The signature proves who sent a request. It does not prove the request is new, so providers that sign a timestamp ask you to reject old ones, and this does. What it also does not prove is what a header says, when the signature does not cover the header:

Provider Signed Not signed, and used Someone who holds one captured delivery can
Standard Webhooks The id, the timestamp and the body, so also the type that names the event Nothing Send it again for five minutes, in either direction from the signed time, under the same name and id, which Inngest drops as a duplicate
Stripe The timestamp and the body, so also the event’s type and id Nothing The same, for the same five minutes
GitHub The body X-GitHub-Event, the event name, and X-GitHub-Delivery, the event id Send it again at any time, under any event name and any id: a push body as github/release, and as often as they like, each time as a new event
Shopify The body X-Shopify-Topic, the event name, X-Shopify-Webhook-Id, the event id, and X-Shopify-Shop-Domain if you add it to the data; X-Shopify-Triggered-At is ignored Send it again at any time, under any topic, any id and any shop
Other HMAC The body, and the timestamp if you name its header The header you name as the delivery id Send it again under any id, and without a timestamp header at any time

For GitHub and Shopify the only defence inside the SDK is Inngest dropping a second event with the same id, and that reaches only so far: it holds for the same id, for 24 hours. A captured request sent again with another id is a new event, and so is any replay after the 24 hours. The delivery id header is required, so a request cannot leave it out to skip that, but it can name another. What helps is outside the webhook: keep the secret secret and the endpoint on HTTPS, allow only the addresses the provider sends from (Stripe and GitHub publish them) at your proxy, narrow what a replay can do with a Transform that returns null for every event name you do not handle, and give a function an idempotency key made of what the signed body contains, such as the id of an object in it, so that a replay under a new event id still does the work once.

The tolerance can be changed per provider, for a server whose clock you cannot keep right, but not switched off. Keep the server’s clock in sync: a wrong clock rejects real webhooks.

Every factory takes one secret or a list. A request is accepted when any signature in it matches any of the secrets, so a secret can be replaced without downtime: put the new one in beside the old one, switch the provider, and remove the old one later.

app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(["whsec_new...", "whsec_old..."]));

When you roll a secret in Stripe and keep the old one, Stripe signs with both for up to 24 hours, and a Standard Webhooks sender signs with the old key and the new one for a while; the verifier checks every signature in the header. Shopify says a signature can take up to an hour to follow a change of the client secret. GitHub signs with the one secret of the webhook.

The secrets are used exactly as written, which is how each provider signs: Stripe’s and GitHub’s as text, Standard Webhooks’ as the base64 after whsec_. A newline at the end of a secret that was read from a file makes every signature fail. Keep them in configuration or a secret store, not in source.

A provider that signs the body with an HMAC and sends the signature in a header needs only options. The signed text is the optional SignedContentPrefix, then the optional timestamp from TimestampHeader and TimestampSeparator, then the body. Slack, for one, signs v0:{timestamp}:{body} and sends v0= and the hex digest in X-Slack-Signature, with a timestamp that has to be within five minutes:

var options = new HmacWebhookOptions
{
SignatureHeader = "X-Slack-Signature",
SignaturePrefix = "v0=",
TimestampHeader = "X-Slack-Request-Timestamp",
SignedContentPrefix = "v0:",
TimestampSeparator = ":",
};
app.MapInngestWebhook("/webhooks/slack", WebhookProvider.Hmac("slack/event", options, secret));

These options are Slack’s signing and not a complete Slack integration. Slack’s Events API also checks a new URL with a url_verification request, which has to be answered with the challenge in the body, and the answers of this endpoint have no body. Handle that request in an endpoint of your own.

Algorithm is SHA-256 unless you set SHA-1, SHA-384 or SHA-512, SignatureEncoding is hexadecimal unless you set base64, and DeliveryIdHeader names the header whose value becomes the event id. Every event has the one name you gave; use a Transform to name events after their content. The body must be JSON. Without a DeliveryIdHeader the events have no id, and nothing deduplicates them. With one, a delivery that lacks the header is answered with 400, and the signed text does not include the header, so whoever holds one captured request can send it again under another id.

For anything else, implement IWebhookVerifier. It sees the raw body and the headers, and has to give an answer for whatever it is handed, because it runs before anyone has proved who they are: return Invalid instead of throwing, and compare secrets with CryptographicOperations.FixedTimeEquals. The reason you give goes to the log, so it must not hold anything from the request.

using System.Security.Cryptography;
using System.Text;
using Inngest;
public sealed class SharedTokenVerifier(string token) : IWebhookVerifier
{
private readonly byte[] _expected = SHA256.HashData(Encoding.UTF8.GetBytes(token));
public WebhookVerification Verify(IWebhookRequest request, DateTimeOffset now)
{
if (request.GetHeader("X-Acme-Token") is not { Length: > 0 } sent)
{
return WebhookVerification.Invalid("the token header is missing");
}
// Digests have the same length, so the comparison does not tell how much of the token was right.
var matches = CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(sent)), _expected);
return matches
? WebhookVerification.Valid(request.GetHeader("X-Acme-Delivery"))
: WebhookVerification.Invalid("the token does not match");
}
}

A WebhookProvider joins the verifier with the code that makes the event of a verified delivery, which returns null for one that cannot be an event:

app.MapInngestWebhook("/webhooks/acme", new WebhookProvider(
"acme",
new SharedTokenVerifier(token),
delivery => delivery.Payload.ValueKind == JsonValueKind.Object
&& delivery.Payload.TryGetProperty("kind", out var kind)
&& kind.ValueKind == JsonValueKind.String
? new InngestEvent("acme/" + kind.GetString(), delivery.Payload) { Id = delivery.DeliveryId }
: null));
Setting Default Notes
MaxBodySize 1 MiB The body is held in memory and read before anything else, so this bounds what an anonymous caller can make the app hold. Inngest refuses events over 256 KiB on its free plan and 3 MiB on Pro and Business, so a larger limit only turns a 413 here into a failed send.
SendTimeout 4 seconds How long to wait for Inngest before answering 503. The client retries a failed send with backoff, which can take longer than a provider waits: Shopify allows five seconds in all.

Both are in WebhookOptions, an immutable record like the SDK’s other options. The endpoint returns an IEndpointConventionBuilder, so rate limiting, host filtering and the rest of ASP.NET Core’s endpoint features apply:

app.MapInngestWebhook("/webhooks/stripe", WebhookProvider.Stripe(secret), new WebhookOptions { MaxBodySize = 256 * 1024 })
.RequireHost("webhooks.example.com");

The request goes through the rest of the pipeline first. Middleware that reads or changes the body makes every signature fail, and a redirect from HTTP to HTTPS is answered with a 3xx that providers count as a failure, so register the HTTPS URL with them. GitHub must be set to send application/json; its form-encoded format is not JSON.

Inngest can also receive webhooks itself: you give the provider a URL at Inngest, and a JavaScript transform you write turns each request into an event. Neither is better everywhere:

Inngest webhooks MapInngestWebhook
Where requests go To Inngest, which accepts them even while your app is down To your app, which has to be up; providers retry for a while (Stripe for up to three days in live mode, Shopify eight times over four hours, GitHub not at all)
Signatures Not verified by Inngest. The transform is handed the raw body and the headers, and the function has to check them after the event exists Verified before an event exists, so a forged request never reaches Inngest
Mapping JavaScript, kept in Inngest C# in your app, with its types and services, under its tests
Event id Whatever the transform sets The provider’s delivery id
Operating Nothing to host An endpoint on the internet to protect and keep up

Hosted webhooks fit when you do not want an endpoint to run, or when a provider’s retries are too short for the downtime you can have. MapInngestWebhook fits when forged requests must never become events, when the mapping is more than a few lines, or when you want your own authorization, rate limiting and tests in front of it. They mix: use whichever fits each provider.

MapInngestWebhook is in Inngest.AspNetCore, which brings in Inngest.Webhooks: the verifiers, the providers and the event conventions. That package depends on Inngest and on nothing of ASP.NET Core, and the verifiers read the raw bytes and the headers through IWebhookRequest, so a host that is not ASP.NET Core, such as an Azure Function or a Lambda that receives the request itself, can verify with them. The host owns the body: the verifiers and the parser read it in place, so it must stay unchanged until the request has been handled, and may be reused or cleared after.

A verifier needs no running endpoint to test, and Verify takes the time it should take for “now”, so a tolerance can be tested without waiting. A Transform can be tried without a server too: new WebhookDelivery(provider, deliveryId, payload, request) makes the delivery it takes, with any IWebhookRequest for the headers, or none. To test an endpoint as a whole, give it the IInngestClient of an InngestTestHost and read SentEvents after the request; each event holds its own copy of the payload, so it can be read then. To try a real provider, stripe listen --forward-to localhost:5000/webhooks/stripe forwards test events to your machine and prints the signing secret to use there.

When a webhook is rejected, the log says why. no signature matches the body for any secret means a wrong secret, a secret of the other mode (Stripe’s test and live endpoints have different ones), a newline at the end of the secret, or a body that something changed on the way. the timestamp is outside the tolerance means a replay or a clock that is wrong.

Related: Sending events explains event ids, and the webhook pipeline example handles a provider by hand and processes each delivery once.

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