| name | add-webhook-handler |
| description | Add a new external webhook receiver (Stripe, ELD, custom integration). Use when wiring an inbound webhook from a third-party service. Codifies signature validation, idempotency, audit logging, and the WebhookController + Application/Commands/Webhooks pattern so security checks are not silently skipped. |
Add a Webhook Handler
Inbound webhooks from external services (Stripe, Samsara, Motive, etc.) terminate at WebhookController and dispatch into the application layer via MediatR commands. Webhook handlers are security-sensitive — every checklist item below exists because something broke when it was missed.
Files that must change
src/Presentation/Logistics.API/Controllers/WebhookController.cs — endpoint route
src/Core/Logistics.Application/Commands/Webhooks/{Provider}{Event}Command.cs + handler
src/Infrastructure/Logistics.Infrastructure.{Module}/{Provider}/{Provider}WebhookService.cs — signature validation
appsettings.json — webhook secret config (matched by env var, never committed)
src/Core/Logistics.Application/Commands/Webhooks/{Provider}{Event}Validator.cs — schema validation
- Tests — at minimum: bad signature returns 400, replay returns 200 idempotent
Step-by-step
1. Decide signature scheme
Most providers use HMAC of the raw body with a shared secret, sent in a header:
| Provider | Header | Algorithm |
|---|
| Stripe | Stripe-Signature | HMAC-SHA256 with timestamp |
| Samsara | X-Samsara-Signature | HMAC-SHA256 |
| Motive | X-Motive-Signature | HMAC-SHA256 |
For a new provider, find their signature spec in their docs before writing any code. If they don't sign webhooks, push back — unsigned webhooks are spoofable and should not be wired in without an alternative (mutual TLS, allowlisted source IPs).
2. Read the raw body
Webhook signature validation is over the raw bytes, not the deserialized DTO. ASP.NET Core has already buffered the body by the time you read it; the controller pattern is:
[HttpPost("provider")]
[AllowAnonymous]
public async Task<IActionResult> Provider(CancellationToken ct)
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Provider-Signature"].ToString();
var result = await mediator.Send(new ProviderWebhookCommand
{
RawBody = rawBody,
Signature = signature
}, ct);
return result.Success ? Ok() : BadRequest();
}
[AllowAnonymous] is correct here — the signature replaces auth.
3. Verify signature in the service layer (constant-time compare)
Infrastructure.{Module}/{Provider}/{Provider}WebhookService.cs:
internal sealed class ProviderWebhookService(IOptions<ProviderOptions> options)
{
public bool VerifySignature(string rawBody, string signature)
{
var secret = options.Value.WebhookSecret;
if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(signature))
return false;
var computed = HmacSha256Hex(secret, rawBody);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(signature));
}
private static string HmacSha256Hex(string secret, string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
Always use CryptographicOperations.FixedTimeEquals — == and string.Equals leak timing information and enable signature forgery.
For Stripe specifically, use Stripe.Webhooks.ConstructEvent(rawBody, signature, secret) from the Stripe SDK — it handles timestamp tolerance and constant-time compare for you.
4. Handler: validate, idempotency, dispatch
internal sealed class ProviderWebhookHandler(
IProviderWebhookService webhookService,
ITenantUnitOfWork tenantUow,
IMasterUnitOfWork masterUow,
ILogger<ProviderWebhookHandler> logger)
: IRequestHandler<ProviderWebhookCommand, Result>
{
public async Task<Result> Handle(ProviderWebhookCommand cmd, CancellationToken ct)
{
if (!webhookService.VerifySignature(cmd.RawBody, cmd.Signature))
{
logger.LogWarning("Webhook signature verification failed");
return Result.CreateError("Invalid signature");
}
var payload = JsonSerializer.Deserialize<ProviderEvent>(cmd.RawBody)
?? throw new InvalidOperationException("Invalid payload");
var alreadyProcessed = await masterUow.Repository<ProcessedWebhook>()
.AnyAsync(w => w.ProviderId == payload.Id && w.Provider == "Provider", ct);
if (alreadyProcessed)
{
logger.LogInformation("Webhook {Id} already processed, skipping", payload.Id);
return Result.CreateSuccess();
}
await masterUow.Repository<ProcessedWebhook>().AddAsync(new ProcessedWebhook
{
ProviderId = payload.Id,
Provider = "Provider",
EventType = payload.Type,
ReceivedAt = DateTime.UtcNow,
RawBody = cmd.RawBody
}, ct);
await masterUow.SaveChangesAsync(ct);
switch (payload.Type)
{
case "thing.created":
await mediator.Send(new HandleProviderThingCreatedCommand(payload), ct);
break;
default:
logger.LogInformation("Unhandled webhook event type {Type}", payload.Type);
break;
}
return Result.CreateSuccess();
}
}
Order matters:
- Signature first — never parse untrusted bytes before verification.
- Then idempotency — same event arriving twice should be a no-op, returning 200.
- Audit log before side effects so you have a record even if the side-effect throws.
- Dispatch by event type; unknown event types should log and return 200, not crash.
5. Config
appsettings.json:
{
"ProviderOptions": {
"WebhookSecret": ""
}
}
Real value comes from env var: ProviderOptions__WebhookSecret. Never commit a real secret.
6. Tests
Minimum coverage:
public class ProviderWebhookHandlerTests
{
[Fact]
public async Task Handle_BadSignature_ReturnsError() { }
[Fact]
public async Task Handle_ReplayedEvent_IsIdempotent() { }
[Fact]
public async Task Handle_UnknownEventType_LogsAndReturnsSuccess() { }
[Fact]
public async Task Handle_ValidEvent_DispatchesCommand() { }
}
Verification checklist
Common mistakes
- Parsing JSON before verifying signature — opens an attack surface for malformed-payload exploits.
- Using
== to compare signatures — timing attacks let attackers forge valid signatures one byte at a time.
- No idempotency — providers retry on 5xx, you double-charge / double-create.
- Returning non-200 for unknown event types — provider keeps retrying forever.
- Logging the raw body at INFO — webhooks often contain PII (emails, names). Use DEBUG and scrub.
- Storing the secret in appsettings.json — it ends up in git history. Always env var or Key Vault.
Provider-specific notes
Stripe
Use the Stripe SDK's built-in verifier. It validates both signature and timestamp tolerance:
var stripeEvent = EventUtility.ConstructEvent(rawBody, stripeSignatureHeader, webhookSecret);
Stripe events have unique id fields — perfect for idempotency keys. Mount the endpoint at /webhooks/stripe (existing convention).
Samsara / Motive (ELD)
Both use X-{Provider}-Signature HMAC-SHA256. Mount at /webhooks/eld/{provider}. The signed payload contains the tenant's ELD account, but you'll typically need to look up the EldProviderConfiguration row to map back to a tenant.
Related
.claude/rules/backend/security.md — webhook signature validation rule
feature-map.md → Operations / Financial / Compliance for the feature being webhooked
WebhookController.cs — existing endpoints to copy from