Webhook processing with idempotency
Webhook senders want a fast 200 and retry when they do not get one, sometimes delivering the same webhook more
than once. The pattern: verify the request, send an event with the delivery id, answer at once, and do the real
work in a function.
This example does the first three steps by hand, for a sender the SDK does not know. For Stripe, GitHub, Shopify and
senders that follow Standard Webhooks, such as Clerk and Resend, MapInngestWebhook does them for you, with the
signature checks written out and the delivery id as the event id: see Webhooks.
using System.Security.Cryptography;using System.Text;using System.Text.Json;using Inngest;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<ILedger, LedgerClient>(client => client.BaseAddress = new Uri("https://ledger.internal/"));builder.Services .AddInngest(options => options.AppId = "payments") .AddFunction<ProcessPaymentWebhook>();
var app = builder.Build();app.MapInngest("/api/inngest");
app.MapPost("/webhooks/payments", async (HttpRequest request, IInngestClient inngest, IConfiguration configuration, CancellationToken cancellationToken) =>{ using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(cancellationToken); if (!IsSigned(body, request.Headers["Webhook-Signature"].ToString(), configuration["PAYMENTS_WEBHOOK_SECRET"]!)) { return Results.Unauthorized(); }
var webhook = JsonSerializer.Deserialize<PaymentWebhook>(body, JsonSerializerOptions.Web)!; await inngest.SendAsync(new InngestEvent(PaymentWebhook.EventName, webhook) { // The same delivery sent twice becomes one event. SendAsync(webhook) would send no id. Id = $"payments-webhook-{webhook.DeliveryId}", }, cancellationToken);
return Results.Ok();});
app.Run();
static bool IsSigned(string body, string signature, string secret){ var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body)); var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hash).ToLowerInvariant()); return CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(signature));}
public sealed record PaymentWebhook(string DeliveryId, string MerchantId, string Type, string PaymentId, long AmountMinor, string Currency) : IInngestEventData{ public static string EventName => "payments/webhook.received";}using System.Net;using Inngest;
public sealed class ProcessPaymentWebhook(ILedger ledger) : InngestFunction<PaymentWebhook>{ public override async Task<object?> ExecuteAsync(FunctionContext<PaymentWebhook> context, CancellationToken cancellationToken) { var webhook = context.Event.Data; if (webhook.Type is not ("payment.succeeded" or "payment.refunded")) { // Retrying will not make an unknown type known. throw new NonRetriableException($"Unsupported webhook type {webhook.Type}."); }
var entryId = await context.Step.RunAsync("record-in-ledger", ct => ledger.RecordAsync(webhook, ct)); return new { webhook.DeliveryId, entryId }; }}
public interface ILedger{ Task<string> RecordAsync(PaymentWebhook webhook, CancellationToken cancellationToken);}
public sealed class LedgerClient(HttpClient http) : ILedger{ public async Task<string> RecordAsync(PaymentWebhook webhook, CancellationToken cancellationToken) { using var response = await http.PostAsJsonAsync("entries", webhook, cancellationToken); if (response.StatusCode == HttpStatusCode.TooManyRequests) { // Come back when the ledger says, instead of on Inngest's own schedule. throw new RetryAfterException("The ledger is rate limiting us.", response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(30)); }
response.EnsureSuccessStatusCode(); var entry = await response.Content.ReadFromJsonAsync<LedgerEntry>(cancellationToken); return entry!.Id; }
private sealed record LedgerEntry(string Id);}The function’s options go on ProcessPaymentWebhook as attributes: eight retries, an idempotency key on the
delivery id and a throttle per merchant.
[InngestFunction("process-payment-webhook", Retries = 8, Idempotency = "event.data.deliveryId")][EventTrigger<PaymentWebhook>][Throttle(10, "1s", Key = "event.data.merchantId")]Program.cs registers it with .AddFunction<ProcessPaymentWebhook>(), without options.
Without the attributes, Program.cs registers the function with its options in code: eight retries, an idempotency
key on the delivery id and a throttle per merchant.
inngest.AddFunction<ProcessPaymentWebhook>(new FunctionOptions{ Id = "process-payment-webhook", Triggers = [Trigger.Event<PaymentWebhook>()], Retries = 8, Idempotency = "event.data.deliveryId", Throttle = new Throttle(10, TimeSpan.FromSeconds(1)) { Key = "event.data.merchantId" },});How it works
Section titled “How it works”- Answer fast, work later. The endpoint verifies the signature, sends one event and returns
200within milliseconds. Slow or failing downstream systems never make the sender retry. - Two layers of deduplication. The event id, built from the delivery id, makes Inngest ignore the same delivery
sent twice within 24 hours.
Idempotencyon the function guards the run as well, in case another path sends the same delivery. - A throttle per merchant. At most ten webhooks per merchant start per second; the rest queue. A merchant with a burst of refunds cannot crowd out the others.
- Retries that listen. A
429from the ledger becomes aRetryAfterExceptionwith the ledger’s own delay. Other failures are retried with Inngest’s backoff, up to eight times. - Fail fast on bad input. An unsupported webhook type throws
NonRetriableException, so it fails once instead of nine times.
The signature check is a sketch: use the verification your payment provider documents, including its timestamp check. Inngest’s guide to receiving webhooks describes a variant where Inngest itself receives the webhook.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.