Skip to content

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.

Program.cs
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";
}
ProcessPaymentWebhook.cs
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.

  • Answer fast, work later. The endpoint verifies the signature, sends one event and returns 200 within 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. Idempotency on 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 429 from the ledger becomes a RetryAfterException with 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.