WebHooks — Sending & Receiving
Key Points
- A webhook = HTTP POST from a system to an arbitrary URL when something happens. Sender retries on 5xx; receiver dedups on idempotency keys.
- Sender responsibilities: HMAC signature, retry with exponential backoff + jitter, dead-letter after N failures, observability (signed deliveries, latency, status).
- Receiver responsibilities: verify signature, validate timestamp (replay window), check idempotency key, return 2xx fast, process async.
- Common HMAC pattern (Stripe/GitHub-style):
signature = HMAC-SHA256(secret, timestamp + "." + body); sender sendsX-Signature: t=…,v1=…. - Don't reinvent — for receivers, libraries exist (Stripe.NET, Octokit, custom HMAC verifier ~30 lines). For senders, use Outbox + a simple HTTP-with-Polly worker.
Concepts (deep dive)
Sender side
event happens → write event to DB (and Outbox table) → background worker reads outbox →
HTTP POST to subscriber → success: mark sent | failure: retry with backoff → exhausted: dead-letter
Key components:
public class WebhookSender(IHttpClientFactory clients, ResiliencePipeline pipeline, ILogger<WebhookSender> log)
{
public async Task<bool> Send(WebhookSubscription sub, WebhookEvent evt, CancellationToken ct)
{
var http = clients.CreateClient("webhook");
var bodyJson = JsonSerializer.Serialize(evt);
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var sig = ComputeSignature(sub.Secret, ts, bodyJson);
return await pipeline.ExecuteAsync(async ct =>
{
var req = new HttpRequestMessage(HttpMethod.Post, sub.Url)
{
Content = new StringContent(bodyJson, Encoding.UTF8, "application/json")
};
req.Headers.Add("X-Signature", $"t={ts},v1={sig}");
req.Headers.Add("X-Idempotency-Key", evt.Id.ToString());
req.Headers.Add("X-Event-Type", evt.Type);
using var resp = await http.SendAsync(req, ct);
return resp.IsSuccessStatusCode;
}, ct);
}
static string ComputeSignature(string secret, long ts, string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{body}"));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
Receiver side
app.MapPost("/webhooks/orders", async (
HttpRequest req,
IConfiguration config,
IIdempotencyStore idemp,
IBackgroundJobQueue jobs,
CancellationToken ct) =>
{
if (!req.Headers.TryGetValue("X-Signature", out var sigHeader) ||
!req.Headers.TryGetValue("X-Idempotency-Key", out var keyHeader))
return Results.Unauthorized();
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync(ct);
if (!VerifySignature(config["Webhooks:Secret"]!, sigHeader!, body, maxAgeSeconds: 300))
return Results.Unauthorized();
if (!await idemp.TryReserve(keyHeader.ToString(), TimeSpan.FromHours(24), ct))
return Results.Ok(); // already processed; idempotent 200
await jobs.Enqueue(new ProcessWebhookJob(body), ct);
return Results.Ok(); // return fast; process async
});
static bool VerifySignature(string secret, string header, string body, int maxAgeSeconds)
{
// X-Signature: t=1714250000,v1=abcdef...
var parts = header.Split(',').Select(p => p.Split('=')).ToDictionary(p => p[0], p => p[1]);
if (!long.TryParse(parts["t"], out var ts) ||
DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts > maxAgeSeconds) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{body}"))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(parts["v1"]),
Encoding.UTF8.GetBytes(expected));
}
Key points: - FixedTimeEquals — constant-time comparison, prevents timing attacks. - Timestamp window (5 minutes) — blocks replay attacks beyond a small clock skew. - Idempotency reservation — same key returns success without re-processing. - Return 2xx fast — heavy work moves to a queue.
Retry strategy
ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 5,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(2), // 2, 4, 8, 16, 32 s
UseJitter = true,
})
.AddTimeout(TimeSpan.FromSeconds(30))
.Build();
Common retry windows: 1m → 5m → 30m → 2h → 12h → dead-letter. Bound total retry duration (e.g., 24 h); after that, the receiver is presumed dead.
Dead-letter queue
After max retries, mark the delivery Failed, store the last response, alert. Operators can replay manually after fixing the receiver.
Outbox pattern (sender side)
To guarantee at-least-once delivery without losing events on crash:
// In the same DB transaction as the business event:
db.Orders.Add(order);
db.Outbox.Add(new OutboxItem { Type = "order.created", Payload = JsonSerializer.Serialize(order) });
await db.SaveChangesAsync();
// Background worker reads outbox, posts webhook, marks sent.
Receiver-side challenge for slow processing
If your receiver's downstream is slow:
Don't process inline — sender's timeout is usually 10-30 s.
Replay protection
Attacker captures a valid signed webhook on the wire.
Sends it again later → receiver:
- Signature still valid (HMAC over same payload)
- But timestamp is older than maxAgeSeconds → reject
Without timestamp validation, replay is trivial.
Verification helpers
For well-known senders (Stripe, GitHub), use their SDKs which know the exact signature format:
For your own platform, document the signature format clearly: which headers, hash algorithm, encoding, what's included in the signed string.
Observability
Sender metrics:
- delivery success rate per subscription
- latency to subscriber (p50, p99)
- retry queue depth
- dead-letter rate
Receiver metrics:
- signature verification failures (potential abuse)
- idempotency duplicate count
- processing latency
Tag traceparent on the outbound HTTP so distributed tracing crosses the system boundary.
Code: correct vs wrong
❌ Wrong: comparing signatures with ==
✅ Correct: constant-time
if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(computed), Encoding.UTF8.GetBytes(provided))) /* ... */
❌ Wrong: processing inline
app.MapPost("/webhook", async (Body b) =>
{
await DoExpensiveWork(b); // takes 90 s; sender times out at 30 s
return Results.Ok();
});
✅ Correct: enqueue and ack
app.MapPost("/webhook", async (Body b, IBackgroundJobQueue q) =>
{
await q.Enqueue(new ProcessJob(b));
return Results.Ok();
});
❌ Wrong: no replay protection
✅ Correct: signed timestamp
Design patterns for this topic
Pattern 1 — Subscription management API
POST /subscriptions { url, eventTypes, secret? } → returns id + secret
PUT /subscriptions/{id}/rotate-secret
DELETE /subscriptions/{id}
Each subscriber has a unique secret stored encrypted at rest.
Pattern 2 — Per-event-type filter on subscription
class WebhookSubscription { public string[] EventTypes; }
// Sender enumerates subscriptions interested in evt.Type
foreach (var sub in subs.Where(s => s.EventTypes.Contains(evt.Type)))
queue.Enqueue(new SendJob(sub, evt));
Pattern 3 — Visible delivery log
Customers can self-diagnose without contacting support.
Pattern 4 — Manual replay UI
After fixing receiver-side bug.
Pattern 5 — Skew-tolerant timestamp window
// Allow ±5 minutes for clock drift between sender/receiver
const int MaxSkewSeconds = 300;
if (Math.Abs(now - ts) > MaxSkewSeconds) return Unauthorized();
Pros & cons / trade-offs
| Aspect | WebHooks | Polling | Message bus |
|---|---|---|---|
| Setup | Customer's URL | Customer's job | Both apps share infra |
| Real-time | Yes | No (poll interval) | Yes |
| Customer side complexity | Public URL + signature verify | API client | Bus client + auth |
| Reliability | Retries + DLQ | Customer-driven | Built-in |
| Cost | Per delivery | Per poll | Per message |
When to use / when to avoid
- Use webhooks for B2B integrations, payment platforms, GitOps notifications, customer-facing automation.
- Avoid when you control both ends and a message bus is available — buses give better guarantees.
- Avoid for high-frequency events (>100 RPS per subscriber) — bus or streaming better.
Interview Q&A
Q1. How do you authenticate webhook deliveries? A. HMAC signature: HMAC-SHA256(secret, timestamp + "." + body) in a header. Receiver recomputes and compares using constant-time comparison.
Q2. Why include a timestamp? A. Prevents replay attacks. The receiver rejects deliveries older than a small window (5 minutes typical), so a captured signed payload can't be replayed later.
Q3. How do you make webhook delivery reliable? A. Outbox pattern at sender (write event in same transaction as business data); background worker retries with exponential backoff + jitter; dead-letter queue after exhausting retries.
Q4. What's idempotency in webhook receivers? A. Each delivery has a unique ID; the receiver records processed IDs (with TTL) and returns 200 on duplicates without re-processing. Senders may retry a successful delivery if the response wasn't received; idempotency makes this safe.
Q5. Why return 2xx fast? A. Senders treat slow responses as failures (timeouts ~10-30 s). Doing real work inline causes unnecessary retries. Enqueue + ack is the standard pattern.
Q6. How do Stripe/GitHub format their signatures? A. Stripe: Stripe-Signature: t=...,v1=... (HMAC-SHA256 of t.body). GitHub: X-Hub-Signature-256: sha256=... (HMAC-SHA256 of body). Different formats; use their SDK if integrating.
Q7. What's a webhook DLQ? A. Dead-letter queue: deliveries that exceeded retry policy land here for manual investigation. Operators replay after fixing receiver, or notify the customer.
Q8. How do you scale webhooks horizontally? A. Outbox + many sender workers polling the queue with leases (one delivery per worker). Spread retries across workers. Keep per-subscriber concurrency limits to avoid hammering slow customers.
Gotchas / common mistakes
- Plain string comparison for signatures — timing attack.
- No timestamp validation — replay attack window is forever.
- Processing inline — sender timeout, retries, duplicate work.
- No idempotency at receiver — duplicate processing on retry.
- Storing secrets in plaintext — encrypt at rest, rotate on rotation API.
- Unbounded retries — keep delivering forever to a dead receiver.
- Tiny timestamp window (<30 s) — clock drift causes false rejects.
- Logging full body with signatures — log size + secret leakage risk.
- No subscription pause — bad receiver brings down sender's queue throughput.