Interview question
Ingest a webhook safely and idempotently
Verifies the exact request bytes, rejects stale signatures, deduplicates event ids, and queues processing durably.
TL;DR
Verifies the exact request bytes, rejects stale signatures, deduplicates event ids, and queues processing durably.
HMAC verification, raw bodies, replay windows, inbox deduplication, outbox work, bounded input, and safe acknowledgements.
Practice the problem like a real interview: restate, reason, implement, and test.
Validate provider timestamp and HMAC over the exact raw body. Persist each provider event id once, enqueue processing atomically, and acknowledge valid duplicates without reapplying them.
The provider retries the same signed event after a timeout; the endpoint returns success but creates no second payment transition.
I authenticate the delivery before deserializing it, then treat the provider event id as an inbox key. The inbox row and outbox command commit together so acknowledgement never outruns durable acceptance.
app.MapPost("/webhooks/payments", async Task<IResult> (HttpRequest request,
AppDbContext db, PaymentWebhookVerifier verifier, CancellationToken ct) =>
{
var body = await request.ReadBoundedBodyAsync(maxBytes: 256_000, ct);
if (!verifier.TryVerify(request.Headers, body, out var verifiedAt))
return Results.Unauthorized();
var age = TimeProvider.System.GetUtcNow() - verifiedAt;
if (age < TimeSpan.FromMinutes(-1) || age > TimeSpan.FromMinutes(5))
return Results.Unauthorized();
PaymentEnvelope? envelope;
try {
envelope = JsonSerializer.Deserialize<PaymentEnvelope>(body);
} catch (JsonException) {
return Results.BadRequest();
}
if (envelope is null || string.IsNullOrWhiteSpace(envelope.EventId))
return Results.BadRequest();
await using var tx = await db.Database.BeginTransactionAsync(ct);
var seen = await db.WebhookInbox.AnyAsync(
x => x.Provider == "payments" && x.EventId == envelope.EventId, ct);
if (seen) return Results.Ok();
db.WebhookInbox.Add(new WebhookInbox("payments", envelope.EventId));
db.OutboxMessages.Add(OutboxMessage.Create("payment.webhook.received",
new { envelope.EventId, Body = body }));
try {
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
} catch (DbUpdateException ex) when (
UniqueConstraints.IsViolation(ex, "UX_WebhookInbox_Provider_EventId")) {
await tx.RollbackAsync(ct);
return Results.Ok(); // A concurrent delivery already persisted this event.
}
return Results.Accepted();
});
The endpoint performs only delivery authentication and durable intake. A unique index on (Provider, EventId) is still required for concurrent duplicates; signature secrets need rotation support without accepting arbitrary old keys forever.
Next in API Implementation Labs: Partitioned Rate Limit