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 over the exact bounded bytes before deserializing it, then treat the provider event id as an inbox key. After validation I build the smallest event envelope the asynchronous handler needs. The inbox row and that outbox command commit together, so acknowledgement never outruns durable acceptance and the default design does not copy an entire payment payload into long-lived storage.
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();
var command = new PaymentWebhookReceived(
envelope.EventId,
envelope.EventType,
envelope.TransactionId,
envelope.OccurredAtUtc);
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", command));
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();
}
return Results.Accepted();
});
The endpoint performs delivery authentication and durable intake only. A unique index on (Provider, EventId) handles concurrent duplicates, and signature-secret rotation must not accept arbitrary old keys forever. If audit or dispute handling truly requires the exact raw payload, store it encrypted behind narrow access with explicit retention and deletion; copying it into every outbox message is not the default.
Next in API Implementation Labs: Partitioned Rate Limit