Skip to content
Shiny.NET

Webhooks

Verify inbound webhook deliveries over the exact bytes that arrived, refuse stale and duplicate deliveries, and sign the webhooks your own service sends, with the Standard Webhooks scheme. Trim- and AOT-clean.

GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.Webhooks
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.Webhooks
using Shiny.AspNet.Webhooks;
app.MapWebhook("/hooks/github", WebhookSignature.GitHub(secret), async (WebhookContext ctx) =>
{
if (ctx.EventType == "push")
await builds.QueueAsync(ctx.ReadFromJson(AppJson.Default.PushEvent)!);
});

That maps a POST route that runs the handler only for deliveries GitHub actually signed. Before the handler runs, the route:

  1. Buffers the raw body, up to MaxBodySize (5 MiB by default). A larger body gets a 413.
  2. Verifies the signature over those exact bytes, comparing in constant time (CryptographicOperations.FixedTimeEquals).
  3. Checks the timestamp for senders that sign one. Five minutes either way by default.
  4. Suppresses a redelivery of an id it has already handled, if you turned that on.

A delivery that fails gets a 401 problem details response that says only “The webhook signature could not be verified.” It is written through Results.Problem, so a registered IProblemDetailsService (builder.Services.AddProblemDetails(...)) shapes it like the rest of your errors. The log records the actual reason (missing header, wrong signature, stale timestamp) as a warning. The response does not say which check failed, so a forger learns nothing about what to fix.

If the handler writes nothing, the response is 200, and that is all a sender looks at. To return a status yourself, use the overload that returns an IResult:

app.MapWebhook("/hooks/stripe", WebhookSignature.Stripe(stripeSecret), async (WebhookContext ctx) =>
{
if (ctx.EventType != "checkout.session.completed")
return Results.Ok(); // acknowledge what you ignore
await orders.FulfilAsync(ctx.ReadFromJson(AppJson.Default.StripeEvent)!);
return Results.Ok();
});

Nothing has to be registered, and there is no middleware to install. Verification is part of the route itself. MapWebhook works on the app or on any route group and returns ASP.NET’s own IEndpointConventionBuilder, so RequireHost, RequireRateLimiting, ExcludeFromDescription and the rest apply as usual.

Factory Sender What it checks Delivery id Event type
WebhookSignature.GitHub(secret) GitHub X-Hub-Signature-256: sha256=<hex> over the body X-GitHub-Delivery X-GitHub-Event
WebhookSignature.Stripe(secret) Stripe Stripe-Signature: t=…,v1=… over {t}.{body}, any of several v1 values, timestamp tolerance the event id in the body the event type in the body
WebhookSignature.StandardWebhooks(secret) Standard Webhooks: Svix, Clerk, Resend, Supabase, OpenAI… webhook-id, webhook-timestamp, webhook-signature: v1,<base64> over {id}.{timestamp}.{body}, several signatures, timestamp tolerance webhook-id type in the body
WebhookSignature.Slack(signingSecret) Slack X-Slack-Signature: v0=<hex> over v0:{timestamp}:{body}, with X-Slack-Request-Timestamp none none
WebhookSignature.Hmac(o => …) anyone else an HMAC of the body in one header optional header optional header

Standard Webhooks secrets are the whsec_… base64 strings senders hand out. The prefix is optional, and a secret that does not decode throws when the verifier is built rather than failing every delivery. The older svix-id / svix-timestamp / svix-signature header names are accepted too. Stripe’s secret is also whsec_…, but Stripe uses it as plain text, which the Stripe verifier does.

Most senders that are not on that list put an HMAC of the body in a single header.

// Shopify: base64 HMAC-SHA256 of the body
var shopify = WebhookSignature.Hmac(o =>
{
o.Name = "shopify";
o.HeaderName = "X-Shopify-Hmac-Sha256";
o.Algorithm = WebhookHmacAlgorithm.Sha256; // Sha1, Sha256, Sha512
o.Encoding = WebhookSignatureEncoding.Base64; // Hex, Base64
o.Secrets.Add(shopifySecret);
o.DeliveryIdHeader = "X-Shopify-Webhook-Id";
o.EventTypeHeader = "X-Shopify-Topic";
});

Set Prefix for a signature written as sha1=…. A header without the prefix is refused, because the prefix is part of the format. This shape signs no timestamp, so it has no replay window. Use duplicate suppression in its place.

A sender whose scheme is not a single HMAC of the body needs its own IWebhookVerifier: one method that gets the headers (IHeaderDictionary), the raw body and the current time. Compare with FixedTimeEquals, not ==.

Every factory takes several secrets. A delivery is accepted when any of them verifies it:

WebhookSignature.GitHub(newSecret, oldSecret);

Add the new secret, move the sender over, then remove the old one. No delivery is refused in between. Stripe and Standard Webhooks senders also send one signature per active key while they rotate, and any one that matches is enough.

Stripe, Slack and Standard Webhooks sign a timestamp. A delivery whose timestamp is more than five minutes away from the server’s clock, in either direction, is refused. Otherwise anyone who captured one delivery could send it again forever. To change the window:

WebhookSignature.Stripe(TimeSpan.FromMinutes(10), stripeSecret);
WebhookSignature.StandardWebhooks(tolerance: null, secret); // off. Only if the sender's clock cannot be trusted at all.

The clock is WebhookOptions.TimeProvider, so a test can move it. A server whose clock has drifted refuses every delivery as stale; the log names the reason (the timestamp … is outside the 300s tolerance), so this is easy to spot.

Senders deliver at least once. They retry on a timeout, on a 5xx, and sometimes for no visible reason. A handler that charges a card or sends an email should not run twice for one event:

app.MapWebhook("/hooks/github", WebhookSignature.GitHub(secret), Handle, o => o.SuppressDuplicates());

A verified delivery whose id was already handled gets a 200 (DuplicateStatusCode), so the sender stops retrying, and the handler does not run. Some details:

  • Only verified deliveries are recorded. A forged request carrying a real delivery id cannot get that id marked as done.
  • A failed attempt is forgotten. If the handler throws or answers 5xx, the id is removed, so the sender’s retry is processed instead of being dropped as a duplicate.
  • Ids are kept per sender. They are stored as github:<id>, so two senders’ ids never collide.
  • The built-in store is bounded. SuppressDuplicates(capacity: 10_000, retention: 24h) keeps keys in memory and drops the oldest first. The ids come from the sender, and an unbounded set of them is a memory leak.

It needs an id. GitHub, Stripe, Standard Webhooks and a generic verifier with DeliveryIdHeader all have one; Slack does not.

The built-in store is per process. To catch duplicates across restarts, or across several instances behind a load balancer, implement IWebhookDeliveryStore over your database or a distributed cache (TryAddAsync must be atomic) and either set o.DeliveryStore or register it in DI:

builder.Services.AddSingleton<IWebhookDeliveryStore, RedisWebhookDeliveryStore>();

A store in WebhookOptions.DeliveryStore wins; the DI registration is used when the options name none.

The body had to be buffered to verify it, so it is on the context as the exact bytes that were verified:

Member
Body ReadOnlyMemory<byte>, as verified
ReadAsString() UTF-8
ReadFromJson(JsonTypeInfo<T>) source-generated, trim-safe
ParseJson() a JsonDocument
DeliveryId, EventType, Timestamp what the verifier found
Verifier the verifier’s name, e.g. github
HttpContext, Request, Response the usual

ctx.Request.Body is replaced with a stream over the same bytes, so code that reads the request the ordinary way still works, for example ReadFormAsync for Slack’s form-encoded slash commands, or minimal-API and MVC [FromBody] binding.

Register verifiers by name when a secret comes from configuration, or when several routes share settings:

builder.Services.AddWebhooks(o =>
{
o.AddVerifier("github", WebhookSignature.GitHub(builder.Configuration["GitHub:WebhookSecret"]!));
o.AddVerifier("stripe", WebhookSignature.Stripe(builder.Configuration["Stripe:WebhookSecret"]!));
o.SuppressDuplicates();
o.MaxBodySize = 1024 * 1024;
});
app.MapWebhook("/hooks/github", "github", HandleGitHub);

A name that was never registered throws when the route is mapped, not on the first delivery.

Option Default
MaxBodySize 5 MiB Largest body buffered for verification. More gets a 413. Kestrel’s MaxRequestBodySize (30 MB) still applies on top.
DeliveryStore null Set by SuppressDuplicates(), or your own store. Falls back to an IWebhookDeliveryStore in DI.
DuplicateStatusCode 200 What a recognised redelivery gets.
TimeProvider system clock What timestamps are checked against.
OnRejected null Replaces the 401. It gets the WebhookVerificationResult, including the reason.

The last argument of MapWebhook adjusts those settings for one route only:

app.MapWebhook("/hooks/big", "github", Handle, o => o.MaxBodySize = 25 * 1024 * 1024);

On existing routes, groups and controllers

Section titled “On existing routes, groups and controllers”

When a route is already mapped, or it is an MVC action, put the requirement on it as endpoint metadata and install the middleware that enforces it:

builder.Services.AddWebhooks(o => o.AddVerifier("github", WebhookSignature.GitHub(secret)));
app.UseWebhookVerification();
// a single route
app.MapPost("/hooks/plain", async (HttpContext ctx) =>
{
var webhook = ctx.GetWebhook();
...
}).RequireWebhookSignature("github");
// a minimal-API handler can take the delivery as a parameter
app.MapPost("/hooks/issues", (WebhookContext webhook) => ...)
.RequireWebhookSignature("github");
// every route in a group
var hooks = app.MapGroup("/hooks/stripe").RequireWebhookSignature("stripe");
[ApiController, Route("hooks")]
[RequireWebhookSignature("github")]
public class HookController : ControllerBase
{
[HttpPost("github")]
public IActionResult GitHub()
{
var webhook = HttpContext.GetWebhook();
...
}
}

.RequireWebhookSignature(...) takes a registered name or an IWebhookVerifier instance, and works on any IEndpointConventionBuilder. The attribute can also be added to a minimal-API route with .WithMetadata(new RequireWebhookSignatureAttribute("github")). On a controller, the attribute covers every action, and an action’s own attribute replaces it.

The middleware reads the endpoint routing picked, so it must run after routing. With WebApplication that is anywhere in the pipeline; if you call UseRouting() yourself, put it after that. It only acts on endpoints that carry the requirement, so every other endpoint is unaffected. If it and MapWebhook both apply to one route, the delivery is still verified and recorded only once, with that route’s own settings.

Your service can also be the sender, notifying subscribers when something happens, or calling another backend. WebhookSigner produces Standard Webhooks signatures that any Standard Webhooks library can verify, including this library’s own verifier and Svix’s:

var secret = WebhookSigner.GenerateSecret(); // "whsec_…", 32 random bytes; share it once
var signer = WebhookSigner.StandardWebhooks(secret);
var body = JsonSerializer.SerializeToUtf8Bytes(evt, AppJson.Default.OrderEvent);
using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new ByteArrayContent(body) };
signer.Apply(request, messageId: evt.Id, body); // webhook-id, webhook-timestamp, webhook-signature
await http.SendAsync(request);

Keep messageId the same when you retry an event, because receivers deduplicate on it. signer.Sign(id, timestamp, body) returns only the v1,… value, for a client that is not HttpClient.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aspnet Skill