Free tools Windows power users keep installed
One-click scans. No signup required.
To receive webhook events in C#, expose a public HTTPS endpoint that accepts POST requests, verify the provider’s signature against the exact raw request body, and durably record the delivery before returning a 2xx response. In ASP.NET Core, you can build that endpoint with either a Minimal API route or a controller. The important reliability and security work is the same in both: authenticate before parsing, make processing idempotent, enforce a request-size limit, and move slow work to a durable queue.
What a webhook receiver needs to do
A webhook is an HTTP request sent by a service when an event occurs. Your application supplies a destination URL; the provider sends an HTTP POST to that URL, usually with event data in the body and metadata such as an event name, delivery ID, or signature in headers. The receiver must be reachable over the public internet, normally using HTTPS.
A successful HTTP response tells the provider that the delivery was accepted. It does not necessarily mean all business processing is complete. A resilient receiver validates the sender, preserves the request as received, prevents duplicate work, and returns success only after it has safely accepted responsibility for the event.
- Require HTTPS and accept only the HTTP methods and content types your provider uses.
- Capture the exact request body bytes and relevant headers.
- Validate the provider’s signature before trusting or deserializing the event.
- Use a stable provider delivery ID to make retries safe.
- Persist or enqueue accepted deliveries before acknowledging them.
- Log useful identifiers and outcomes, but not secrets or sensitive payload contents.
Choose Minimal APIs or a controller
Minimal API
Use a Minimal API when the webhook is a small, focused endpoint in an ASP.NET Core application. Microsoft’s Minimal API reference documents route handlers such as MapPost and their request and validation support.
Recommended Free Tools
#1 Best Overall
Controller
Use a controller when your project already uses MVC conventions, attribute routing, filters, or controller-level organization. ASP.NET Core controllers derive from ControllerBase; [ApiController] and [Route] are standard routing attributes. See Microsoft’s Web API controller documentation.
Build a Minimal API receiver
This runnable starting point targets modern ASP.NET Core. It reads the body as bytes, checks a GitHub-style SHA-256 signature, and makes room for durable idempotent acceptance. Replace the marked persistence step with a database transaction or durable queue before relying on it in production.
using System.Security.Cryptography;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
// Set a limit appropriate to your provider and deployment.
options.Limits.MaxRequestBodySize = 25 * 1024 * 1024;
});
var app = builder.Build();
app.MapPost("/webhooks/github", async (HttpRequest request, IConfiguration config) =>
{
if (!request.HasJsonContentType())
return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
var secret = config["Webhooks:GitHubSecret"];
if (string.IsNullOrEmpty(secret))
return Results.StatusCode(StatusCodes.Status500InternalServerError);
byte[] body;
try
{
await using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, request.HttpContext.RequestAborted);
body = buffer.ToArray();
}
catch (BadHttpRequestException)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
var signature = request.Headers["X-Hub-Signature-256"].ToString();
if (!IsValidGitHubSignature(body, signature, secret))
return Results.Unauthorized();
var deliveryId = request.Headers["X-GitHub-Delivery"].ToString();
var eventName = request.Headers["X-GitHub-Event"].ToString();
if (string.IsNullOrWhiteSpace(deliveryId) || string.IsNullOrWhiteSpace(eventName))
return Results.BadRequest();
// TODO: In one durable operation, insert deliveryId under a unique constraint
// and enqueue the verified body/event metadata. If deliveryId already exists,
// treat it as an accepted duplicate and do not enqueue it again.
// Return success only after that operation commits.
using var document = JsonDocument.Parse(body);
// Dispatch supported event types from a background worker, not in this request.
return Results.Ok();
});
app.Run();
static bool IsValidGitHubSignature(byte[] body, string header, string secret)
{
const string prefix = "sha256=";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return false;
byte[] supplied;
try
{
supplied = Convert.FromHexString(header[prefix.Length..]);
}
catch (FormatException)
{
return false;
}
var expected = HMACSHA256.HashData(System.Text.Encoding.UTF8.GetBytes(secret), body);
return supplied.Length == expected.Length &&
CryptographicOperations.FixedTimeEquals(expected, supplied);
}
Store Webhooks:GitHubSecret in a secret manager or environment-specific configuration, not in source control. The code’s JSON content-type check is appropriate for a JSON-only route; GitHub also documents URL-encoded payloads, so configure the webhook to send JSON or implement the provider’s form-encoded contract deliberately. GitHub documents X-Hub-Signature-256 as an HMAC-SHA-256 digest of the request body and X-GitHub-Delivery as a globally unique delivery identifier. Its webhook payload documentation states that payloads are capped at 25 MB. See GitHub Webhook events and payloads.
Build the equivalent controller endpoint
A controller can apply the same logic while fitting an MVC application. Avoid automatic model binding for the payload before signature validation: read the raw body first, validate it, then parse it.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Rank #2
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text.Json;
[ApiController]
[Route("api/webhooks/github")]
public sealed class GitHubWebhookController : ControllerBase
{
private readonly IConfiguration _config;
private readonly IWebhookStore _store;
public GitHubWebhookController(IConfiguration config, IWebhookStore store)
{
_config = config;
_store = store;
}
[HttpPost]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task Receive(CancellationToken cancellationToken)
{
if (!Request.HasJsonContentType())
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
var secret = _config["Webhooks:GitHubSecret"];
if (string.IsNullOrEmpty(secret))
return StatusCode(StatusCodes.Status500InternalServerError);
await using var buffer = new MemoryStream();
await Request.Body.CopyToAsync(buffer, cancellationToken);
var body = buffer.ToArray();
var signature = Request.Headers["X-Hub-Signature-256"].ToString();
if (!WebhookSignatures.IsValidGitHubSignature(body, signature, secret))
return Unauthorized();
var deliveryId = Request.Headers["X-GitHub-Delivery"].ToString();
var eventName = Request.Headers["X-GitHub-Event"].ToString();
if (string.IsNullOrWhiteSpace(deliveryId) || string.IsNullOrWhiteSpace(eventName))
return BadRequest();
using var document = JsonDocument.Parse(body);
var accepted = await _store.AcceptOnceAsync(
deliveryId, eventName, body, cancellationToken);
// Existing delivery IDs are already accepted; do not perform the work twice.
return accepted ? Accepted() : Ok();
}
}
public static class WebhookSignatures
{
public static bool IsValidGitHubSignature(byte[] body, string header, string secret)
{
const string prefix = "sha256=";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
byte[] supplied;
try { supplied = Convert.FromHexString(header[prefix.Length..]); }
catch (FormatException) { return false; }
var expected = HMACSHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(secret), body);
return supplied.Length == expected.Length &&
CryptographicOperations.FixedTimeEquals(expected, supplied);
}
}
public interface IWebhookStore
{
// Must atomically persist the delivery and arrange durable processing.
// Return true only for a newly accepted delivery.
Task<bool> AcceptOnceAsync(string deliveryId, string eventName,
byte[] body, CancellationToken cancellationToken);
}
The IWebhookStore contract is intentionally explicit: returning from this method must mean the event is durably recorded or queued, not merely held in memory. A database uniqueness constraint on the delivery ID is a straightforward way to protect against concurrent retries. In a real implementation, ensure that writing the delivery record and creating the work item are atomic, or use a transactional outbox pattern so a process crash cannot leave an accepted event without its work queued.
Verify signatures over the exact raw body
Signature verification protects the endpoint from forged or altered payloads. The signature is calculated over bytes, not over an equivalent-looking JSON object. Parsing and reserializing JSON can change whitespace, property order, escaping, or encoding, which changes the bytes and invalidates the digest.
- Read and retain the request body bytes exactly once before JSON parsing.
- Read the provider’s designated signature header and reject a missing, malformed, or invalid value.
- Compute the expected MAC using the provider’s documented algorithm and secret.
- Compare the supplied and expected byte sequences with a constant-time comparison.
- Only after verification succeeds, parse the body and trust event fields for routing.
The helper above implements GitHub’s sha256= hexadecimal HMAC-SHA-256 format. It is not a universal webhook verifier. Other providers may sign a timestamp plus body, use a different header, encoding, key format, or canonicalization rule. Implement the provider’s exact specification, including timestamp tolerance where applicable, and support secret rotation according to that provider’s guidance. Never accept an unsigned body just because it parses as valid JSON.
Make deliveries idempotent and acknowledgements durable
Webhook providers commonly retry when they do not receive a successful response promptly or when network conditions make the outcome uncertain. Consequently, the same event may arrive more than once—even if your original handler completed but the response was lost. Treat delivery as at-least-once unless your provider explicitly guarantees otherwise.
Use a stable key
Use the provider’s delivery identifier as the deduplication key. For GitHub, that is X-GitHub-Delivery. Enforce uniqueness in durable storage rather than relying on a process-local set or cache: multiple application instances and restarts make in-memory deduplication incomplete.
Accept, enqueue, acknowledge
Keep the synchronous HTTP path short. After signature and basic contract checks, atomically record the delivery and put the work into a durable queue (or use an outbox). Return a 2xx only when the system can safely recover and process that accepted work. If storage or queue acceptance fails, return a non-2xx response so the provider’s retry policy can operate. Do not return success after placing work only in an in-memory background task.
Process and recover
A background worker can parse the verified payload, check the event type, run business logic, and mark the work complete. Make business-side effects idempotent too: a queue may redeliver an item, and an event-level dedupe record alone does not make an external payment, email, or update exactly-once. Define retry limits, dead-letter handling, and a safe replay procedure for failures.
Handle payload types, limits, and event contracts
Webhook headers and payload formats are provider-specific. GitHub sends headers including X-GitHub-Event, X-GitHub-Delivery, and X-Hub-Signature-256; it supports JSON or URL-encoded payloads. Choose and enforce the format configured at the provider, rather than silently treating arbitrary request bodies as JSON.
Rank #4
- Apply a request-size limit at the application server and, if applicable, the reverse proxy or gateway. Set it to a value that accommodates the provider’s legitimate maximum without allowing unbounded memory use.
- For GitHub, the documented maximum payload is 25 MB; this is a provider limit, not a recommendation that every application accept that much.
- Validate required headers and content type before handing work to downstream code.
- Dispatch only event names your application supports; safely acknowledge or deliberately reject valid but irrelevant event types according to your integration design.
- Plan for schema evolution. Treat optional fields as optional and avoid assuming that unknown fields or event variants cannot appear.
Deploy securely and operate the endpoint
Configure the webhook URL to reach the correct production or test environment and terminate HTTPS at a trusted host or reverse proxy. Ensure the original request body is not transformed before your application verifies it. If a proxy decompresses, rewrites, or otherwise alters the body, verification can fail; verify against the exact bytes the provider signed.
Log the delivery ID, event type, receipt time, verification outcome, queue result, processing duration, and failure category. Redact authorization values and signing secrets, and avoid logging whole payloads unless you have a justified retention and access-control policy. Track metrics for rejected signatures, duplicate deliveries, queue depth, processing failures, and end-to-end latency. Keep secrets out of application code and rotate them through the provider’s supported process.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Provider reports connection failure or timeout | URL is not publicly reachable, TLS or DNS is misconfigured, route or method is wrong, or the handler waits on slow business work. | Verify the public HTTPS URL and POST route, inspect proxy and application logs, and move lengthy processing behind durable acceptance. |
| Signature validation fails for a seemingly valid event | The code validates reserialized JSON instead of the raw bytes, uses the wrong secret or header, or assumes another provider’s signature format. | Capture raw bytes before parsing; verify the configured secret and provider-specific algorithm, encoding, header format, and timestamp requirements. |
| Request is rejected with 413 | A server, proxy, or application body-size limit is below the delivery size. | Check limits at every layer and raise them only as far as the provider contract and memory budget justify. |
| Same event triggers duplicate business actions | Delivery IDs are not stored durably with a uniqueness constraint, or downstream processing is not idempotent. | Deduplicate transactionally and make side effects safe under retries; do not rely on a local in-memory cache. |
| Provider retries despite the endpoint appearing to work | The response was not 2xx, arrived too late, or the service crashed before durable acceptance. | Return success promptly after a committed queue/record operation; inspect delivery IDs and response codes in logs. |
| Valid event is rejected as unsupported media type | The provider sends form encoding while the route permits JSON only, or the content-type header is unexpected. | Align provider configuration and receiver behavior, or implement the documented alternate payload format before signature-aware parsing. |
| Events pass validation but no handler runs | Event name routing is incomplete, a background worker is unhealthy, or accepted work is stranded in the queue. | Check event headers, worker health, queue depth, dead letters, and the supported-event mapping. |
Optional Stripe-specific integration
For Stripe on ASP.NET Core, the NuGet package Stripe.Extensions.AspNetCore advertises event parsing, signature validation, logging, and handler registration through MapStripeWebhookHandler. It is an optional provider-specific dependency, not a general webhook framework. Check the package’s current version, API, compatibility, and Stripe’s current webhook instructions before adopting it; the package description states that it automates those handling tasks.
Or skip the browser setup
Webhook receiver code is for incoming event POSTs. If your integration also needs screenshots of pages linked from events—for example, for an audit or review workflow—you can call ScreenshotNeo, a website screenshot API and MCP server made by Yorker Media. One GET request can return a PNG, JPEG, WebP, or PDF. Its consent-banner, popup, and chat-widget cleanup can be turned off step by step; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, with response headers indicating the page verdict and billing status. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for AI agents.
For the API key and request options, see the ScreenshotNeo documentation. Example cURL call:
Best Value
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Sign up for ScreenshotNeo free.
Frequently asked questions
Should a webhook endpoint return 200 or 202?
Either can represent successful acceptance, depending on your API convention. What matters is that a 2xx response is sent only after you have durably recorded or queued the event; the provider generally does not need to wait for your business operation to finish.
Can I test a webhook on localhost?
A provider cannot reach a private localhost URL directly. For development, use the provider’s supported local forwarding or tunneling workflow, and keep test secrets separate from production credentials.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorsShould I return an error for an event type I do not handle?
Choose intentionally. If the event is valid and irrelevant, acknowledging it avoids repeated deliveries that will never become useful. Return an error only when retrying could resolve a temporary acceptance problem or when your provider contract requires rejection.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

