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 | |
| Downloads |
dotnet add package Shiny.AspNet.Webhooksusing 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:
- Buffers the raw body, up to
MaxBodySize(5 MiB by default). A larger body gets a413. - Verifies the signature over those exact bytes, comparing in constant time
(
CryptographicOperations.FixedTimeEquals). - Checks the timestamp for senders that sign one. Five minutes either way by default.
- 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.
The built-in verifiers
Section titled “The built-in verifiers”| 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.
Anything else: the generic HMAC verifier
Section titled “Anything else: the generic HMAC verifier”Most senders that are not on that list put an HMAC of the body in a single header.
// Shopify: base64 HMAC-SHA256 of the bodyvar 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 ==.
Rotating secrets
Section titled “Rotating secrets”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.
Replay protection
Section titled “Replay protection”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.
Duplicate deliveries
Section titled “Duplicate deliveries”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.
Reading the body
Section titled “Reading the body”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.
Named verifiers, shared settings
Section titled “Named verifiers, shared settings”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 routeapp.MapPost("/hooks/plain", async (HttpContext ctx) =>{ var webhook = ctx.GetWebhook(); ...}).RequireWebhookSignature("github");
// a minimal-API handler can take the delivery as a parameterapp.MapPost("/hooks/issues", (WebhookContext webhook) => ...) .RequireWebhookSignature("github");
// every route in a groupvar 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.
Signing the webhooks you send
Section titled “Signing the webhooks you send”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 oncevar 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-signatureawait 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.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne 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.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne 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.


