Design a Webhook Delivery System
Design a Webhook Delivery System
Every payments platform ends up building the same subsystem: a way to tell a merchant's server "the charge succeeded" without that merchant polling your API forever. Stripe calls it Events & Webhooks. Razorpay calls it Webhooks. Both ship the exact same three mechanisms under the hood, and both publish enough of their own docs that you can build the real thing instead of a toy. This lab is the sender side of that system — a delivery worker that pushes events to subscriber URLs — plus the receiver-side contract a subscriber MUST implement to not get burned by it. The interview-relevant gap: anyone can draw "queue → POST to URL → done" on a whiteboard. The senior-level version is explaining, with numbers, why that naive version either double-charges a customer or DDoSes your own subscriber, and which two mechanisms close each hole. See also the concept-level comparison of Polling vs LongPolling vs WebSockets vs Webhooks for why push (webhooks) wins here over the alternatives before you build any of this.
1. The trap
You ship the obvious version first: a background job that, on payment.succeeded, does a single synchronous POST to the merchant's configured URL, right there inside the request/response cycle that processed the payment. It works in the demo. Then three things happen in production, in this order, and each one is a real incident shape reported by every payments platform that has ever shipped webhooks:
- The merchant's server times out. Their endpoint is slow, or briefly down for a deploy. Your synchronous
POSTblocks the thread that just finished charging the customer's card — now your payment API's p99 latency is however slow their server is, for an event they may not even care about synchronously. One flaky subscriber degrades every merchant's checkout. - The response never arrives, but the work happened. The merchant's server received the POST, credited the customer's wallet, and then the response got dropped by a load balancer mid-flight. From your side, this looks identical to "the request never arrived" — so you retry. The merchant now processes the same
payment.succeededevent twice. If their handler isn't written to expect that, the customer's wallet gets credited twice for one payment. This is not a hypothetical: it is the single most common webhook-integration bug reported across every platform's own postmortems and support docs. - You "fix" the timeout by retrying immediately, in a loop, with no delay. The merchant's endpoint is down for maintenance for four minutes. Your naive retry-on-failure loop, with zero backoff, fires again the instant the first attempt fails — thousands of times a second, for four straight minutes, at a server that is already down. You just used your own reliability feature to DDoS a customer. Movement 5 reproduces this with exact request counts.
Both failure shapes trace back to the same root cause: at-least-once delivery is the ONLY delivery guarantee that is actually achievable over an unreliable network (Movement 3 derives why), and at-least-once means duplicates are not a bug in your system — they are a guaranteed, expected input that the receiver must be built to handle. See Traffic Amplification: Fan-out & Retry Storms for the general shape of the second failure at fleet scale.
2. Scope it like a senior
Before writing a line of code, pin down the questions that decide the whole design:
- What's the delivery guarantee, and who owns handling its consequence? At-most-once silently drops events under any failure — unacceptable for "your payment succeeded." Exactly-once is not achievable end-to-end over a network you don't control (Movement 3). That leaves at-least-once as the sender's guarantee, which means: the receiver MUST dedup. This is a contract between the two sides, not a sender-only concern — say this out loud, it's the single most important scoping answer in this whole design.
- Sync-in-request or queue+worker? Firing the webhook inside the request that triggered it couples your critical path's latency to a third party's uptime. The only sane answer for anything payments-adjacent is: the triggering request enqueues an event durably (so a crash after enqueue doesn't lose it) and returns immediately; a separate worker pool drains the queue and does the actual HTTP calls.
- What's the retry budget? Infinite retries forever is its own failure mode (a permanently-down subscriber accumulates unbounded backlog). Stripe's own docs describe a real schedule: immediate, then roughly 5m → 30m → 2h → 5h → 10h → 10h, continuing for up to 3 days, after which the endpoint is flagged. Razorpay's docs describe an exponential backoff schedule over roughly 24 hours before disabling a chronically-failing endpoint. Both converge on the same shape: exponential backoff, a cap, then give up and disable — not different ideas, the same idea at different time budgets.
- How does the receiver prove the request is really from you, not an attacker who found the URL? The endpoint is a public URL. Anyone can POST to it. The receiver needs to verify the sender's identity AND that the request isn't a captured-and-replayed old one.
- Ordering guarantees? Do NOT promise in-order delivery across events for the same resource unless you've explicitly built for it (a per-resource sequence number the receiver checks) — retries+backoff on a flaky endpoint naturally reorder events relative to a healthy one. Most real systems (Stripe included) explicitly say "do not assume events arrive in the order they occurred" for exactly this reason.
3. Reason to the design
Attempt 0 — deliver, and if it fails, don't retry at all (at-most-once). Simplest possible thing: POST once, log success/failure, move on. This fails the product requirement immediately — a transient blip in the merchant's server (a deploy, a GC pause, one dropped TCP packet) silently loses a "your payment succeeded" notification forever. For anything involving money, silently dropping the notification is worse than any of the alternatives below. Rejected.
Attempt 1 — retry until it succeeds, immediately (naive at-least-once). Fixes the silent-drop problem: keep retrying on failure. But "immediately" is the trap from Movement 1 — a subscriber that is down or slow gets hit at the sender's maximum possible request rate, which is exactly the retry-storm failure. You've traded "silently loses events" for "can DDoS a struggling subscriber," which is arguably worse, since now the retries themselves are actively making the subscriber's outage longer.
Attempt 2 — retry with a FIXED delay (e.g. always wait 5 seconds). Better: at least you're not hammering at max speed. But a fixed delay is wrong at both ends of the failure spectrum. Too short for an outage measured in hours (you still fire thousands of retries over that outage). Too long for a one-off blip that would've recovered in 200ms (you've now added needless latency to a notification that could have succeeded quickly). You need the delay to grow with how long the failure has persisted — you don't know in advance whether this is a 200ms blip or a 6-hour outage, so let the schedule discover it.
Attempt 3 — exponential backoff: double the delay each failed attempt, cap it. delay = min(maxDelay, base * 2^(attempt-1)). This is directionally right and is the core of both Stripe's and Razorpay's real schedules. But pure exponential backoff has a subtler failure mode at fleet scale: if a subscriber's endpoint goes down and you have thousands of events queued for it (a batch of payments all failed webhooks at once), every one of those deliveries computes the exact same delay at the exact same wall-clock moment — because they all started failing together and the formula is deterministic. They all retry in lockstep, in one synchronized burst, at attempt 2's delay, then attempt 3's delay, and so on. That's the "thundering herd" pattern: not continuous hammering, but periodic synchronized spikes.
The fix: jitter. Instead of retrying at exactly delay, retry at a random point between 0 and delay — AWS's "full jitter" formula, which is what both this lab's reference implementations use. Now each queued delivery for the same down subscriber picks a different actual wait time, spreading what would have been one synchronized burst into a smooth trickle. Exponential backoff solves "don't retry too fast forever"; jitter solves "don't retry all-at-once even when slow." You need both — see The Architecture of the Retry Pattern for the general form of this mechanism outside the webhook context.
The final piece: give up. An endpoint that is down for days will exhaust any finite retry budget. At that point the correct move isn't "retry forever" (unbounded backlog, unbounded cost) — it's give up on this attempt, and if the endpoint has been failing continuously past some threshold, auto-disable it and notify the merchant out-of-band (email, dashboard banner) that their webhook endpoint is broken. This is exactly what both Stripe and Razorpay do.
4. Build it — milestones
Attempt each milestone before reading the reference implementation below.
- Core contract (sender): a durable queue of
Event{id, payload}; a worker pool that pops an event and callsdeliver(event, subscriberURL); on failure, retry with backoff+jitter up tomaxAttempts; on final failure, give up and (past a threshold) auto-disable the subscriber. - M1 — the naive delivery, no retry. One POST, log the result. Confirm the happy path end to end before adding any resilience — you can't reason about backoff against a delivery path you haven't proven correct first.
- M2 — add retry with full-jitter exponential backoff.
delay = random(0, min(maxDelay, base * 2^(attempt-1))). Verify: a flapping endpoint that fails 3 times then recovers gets delivered on attempt 4, with growing (jittered) gaps between attempts. - M3 — give up + auto-disable. After
maxAttemptswith no success, stop retrying this event and mark the subscriber's endpoint as failing; after enough consecutive failed events, flip the endpoint todisabledso you stop queuing new deliveries to a dead URL at all. - M4 — sign every request. HMAC-SHA256 over
timestamp + "." + payloadusing a per-subscriber shared secret; send the timestamp and signature as headers. This is the sender's half of the receiver's verification contract (M5 below). - M5 (receiver side) — verify, dedup, ack-fast-then-process-async. On receipt: verify the signature AND that the timestamp is within a tolerance window (reject stale/replayed requests); check the event id against a dedup store (skip the side effect, still return 200, if already seen); return 2xx immediately; do the real work (crediting a wallet, updating an order) on a background worker, off the request thread that's talking to the sender.
Reference implementation — Java, sender (delivery worker: backoff + jitter + HMAC signing)
The whole retry-storm fix lives in one method, backoffDelayMs: exponential growth, a hard cap, then full jitter. The signing binds the timestamp INTO the signed bytes, which is exactly why a captured-and-replayed request can't be re-signed later with a forged fresh timestamp.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
/** Sender side: at-least-once delivery with exponential backoff + full jitter,
* HMAC-SHA256 signing with a timestamp, give-up + auto-disable after N attempts. */
public final class WebhookSender {
// ---- the event and the "subscriber" (stand-in for an HTTP POST over the wire) ----
static final class Event {
final String id;
final String payload;
Event(String id, String payload) { this.id = id; this.payload = payload; }
}
static final class DeliveryResult {
final int statusCode;
DeliveryResult(int statusCode) { this.statusCode = statusCode; }
boolean isSuccess() { return statusCode >= 200 && statusCode < 300; }
}
interface Subscriber {
DeliveryResult deliver(String payload, String eventId, long timestamp, String signature);
}
// ---- HMAC-SHA256 signing, timestamp bound into the signed message (anti-replay) ----
static final class HmacSigner {
static String sign(String secret, String payload, long timestampEpochSeconds) {
try {
String signedPayload = timestampEpochSeconds + "." + payload; // Stripe's own scheme: t.payload
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(raw.length * 2);
for (byte b : raw) hex.append(String.format("%02x", b));
return hex.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
// ---- the delivery worker: pulls an event, POSTs, retries with backoff+jitter, gives up ----
static final class DeliveryWorker {
final String secret;
final int maxAttempts;
final long baseDelayMs;
final long maxDelayMs;
final boolean useBackoff; // false => immediate retry (Movement 5's retry-storm bug)
final Random rng = new Random(42); // fixed seed: reproducible demo numbers
DeliveryWorker(String secret, int maxAttempts, long baseDelayMs, long maxDelayMs, boolean useBackoff) {
this.secret = secret;
this.maxAttempts = maxAttempts;
this.baseDelayMs = baseDelayMs;
this.maxDelayMs = maxDelayMs;
this.useBackoff = useBackoff;
}
/** full-jitter exponential backoff: delay = random(0, min(maxDelay, base * 2^(attempt-1))) */
long backoffDelayMs(int attempt) {
if (!useBackoff) return 0L; // the bug under test in Movement 5
long capped = Math.min(maxDelayMs, baseDelayMs * (1L << (attempt - 1)));
return (long) (rng.nextDouble() * capped); // AWS "full jitter" formula
}
/** Returns true if delivered, false if the endpoint was given up on / auto-disabled. */
boolean deliver(Event e, Subscriber sub, StringBuilder log) {
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
long ts = System.currentTimeMillis() / 1000L;
String sig = HmacSigner.sign(secret, e.payload, ts);
DeliveryResult r = sub.deliver(e.payload, e.id, ts, sig);
if (r.isSuccess()) {
log.append(String.format(" attempt %d -> %d OK, delivered%n", attempt, r.statusCode));
return true;
}
log.append(String.format(" attempt %d -> %d FAIL%n", attempt, r.statusCode));
if (attempt < maxAttempts) {
long delay = backoffDelayMs(attempt);
log.append(String.format(" backing off %d ms before retry %d%n", delay, attempt + 1));
sleep(delay);
}
}
log.append(String.format(" GIVE UP after %d attempts -> endpoint AUTO-DISABLED%n", maxAttempts));
return false;
}
private static void sleep(long ms) {
if (ms <= 0) return;
try { Thread.sleep(ms); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
}
}
// ---- fake subscribers used to exercise the worker ----
/** Fails the first `failCount` calls, then succeeds -- a flapping endpoint recovering. */
static final class FlappingSubscriber implements Subscriber {
private final AtomicInteger calls = new AtomicInteger(0);
private final int failCount;
FlappingSubscriber(int failCount) { this.failCount = failCount; }
public DeliveryResult deliver(String payload, String eventId, long ts, String sig) {
int n = calls.incrementAndGet();
return n <= failCount ? new DeliveryResult(503) : new DeliveryResult(200);
}
int callCount() { return calls.get(); }
}
/** Never recovers -- exercises the give-up / auto-disable path. */
static final class AlwaysFailingSubscriber implements Subscriber {
public DeliveryResult deliver(String payload, String eventId, long ts, String sig) {
return new DeliveryResult(500);
}
}
/** An always-failing endpoint that answers instantly -- used to measure request rate. */
static final class InstantFailSubscriber implements Subscriber {
private final AtomicInteger calls = new AtomicInteger(0);
public DeliveryResult deliver(String payload, String eventId, long ts, String sig) {
calls.incrementAndGet();
return new DeliveryResult(500);
}
int callCount() { return calls.get(); }
}
public static void main(String[] args) throws Exception {
String secret = "whsec_demo_shared_secret";
System.out.println("=== 1. Normal delivery, first attempt succeeds ===");
DeliveryWorker normal = new DeliveryWorker(secret, 6, 100, 2000, true);
StringBuilder log1 = new StringBuilder();
boolean ok1 = normal.deliver(new Event("evt_1", "{\"type\":\"payment.succeeded\"}"),
new FlappingSubscriber(0), log1);
System.out.print(log1);
System.out.println("delivered=" + ok1);
System.out.println();
System.out.println("=== 2. Flapping endpoint: fails 3x, then recovers under backoff+jitter ===");
DeliveryWorker backoffWorker = new DeliveryWorker(secret, 6, 100, 2000, true);
StringBuilder log2 = new StringBuilder();
long t0 = System.currentTimeMillis();
boolean ok2 = backoffWorker.deliver(new Event("evt_2", "{\"type\":\"invoice.paid\"}"),
new FlappingSubscriber(3), log2);
long elapsed2 = System.currentTimeMillis() - t0;
System.out.print(log2);
System.out.println("delivered=" + ok2 + ", wall time=" + elapsed2 + "ms (attempts spaced out by design)");
System.out.println();
System.out.println("=== 3. Endpoint never recovers: give up after maxAttempts, auto-disable ===");
DeliveryWorker giveUpWorker = new DeliveryWorker(secret, 5, 50, 800, true);
StringBuilder log3 = new StringBuilder();
boolean ok3 = giveUpWorker.deliver(new Event("evt_3", "{\"type\":\"charge.failed\"}"),
new AlwaysFailingSubscriber(), log3);
System.out.print(log3);
System.out.println("delivered=" + ok3 + " -> subscriber endpoint would be marked DISABLED in the DB");
System.out.println();
System.out.println("=== 4. BREAK IT (b): immediate retry (no backoff) vs backoff -- the retry storm ===");
final long windowMs = 300;
// (a) no backoff at all: hammer the subscriber as fast as the loop can spin.
InstantFailSubscriber stormTarget = new InstantFailSubscriber();
DeliveryWorker stormWorker = new DeliveryWorker(secret, Integer.MAX_VALUE, 0, 0, false); // useBackoff=false
long stormStart = System.currentTimeMillis();
int stormAttempt = 0;
while (System.currentTimeMillis() - stormStart < windowMs) {
long ts = System.currentTimeMillis() / 1000L;
String sig = HmacSigner.sign(secret, "{}", ts);
stormTarget.deliver("{}", "evt_storm", ts, sig);
stormAttempt++;
}
System.out.println(String.format(
" NO-BACKOFF worker hammered the endpoint %d times in %dms (~%.0f req/s)",
stormTarget.callCount(), windowMs, stormTarget.callCount() * 1000.0 / windowMs));
// (b) same window, WITH exponential backoff+jitter: far fewer requests reach the subscriber.
InstantFailSubscriber politeTarget = new InstantFailSubscriber();
DeliveryWorker politeWorker = new DeliveryWorker(secret, Integer.MAX_VALUE, 50, 500, true);
long politeStart = System.currentTimeMillis();
int politeAttemptCount = 0;
while (System.currentTimeMillis() - politeStart < windowMs) {
long ts = System.currentTimeMillis() / 1000L;
String sig = HmacSigner.sign(secret, "{}", ts);
politeTarget.deliver("{}", "evt_storm", ts, sig);
politeAttemptCount++;
long delay = politeWorker.backoffDelayMs(politeAttemptCount);
try { Thread.sleep(delay); } catch (InterruptedException ignored) {}
}
System.out.println(String.format(
" BACKOFF worker made only %d request(s) to the same endpoint in the same %dms window (~%.0f req/s)",
politeTarget.callCount(), windowMs, politeTarget.callCount() * 1000.0 / windowMs));
System.out.println(" Same failing endpoint, same wall-clock window -- backoff is the only difference,"
+ " and it is the difference between a request storm and a handful of polite retries.");
}
}
Compiled and executed with javac/java (JDK 8) before publishing — zero warnings, zero errors.
Reference implementation — Java, receiver (verify + dedup + fast-2xx-then-async)
Three independent responsibilities, none optional: Verifier proves the request is really from you and isn't a replay; SafeReceiver makes the side effect idempotent under duplicate delivery; AsyncReceiver decouples "tell the sender we got it" from "actually do the work."
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/** Receiver side: verify signature + timestamp (reject replays), dedup by event-id
* (at-least-once means duplicates ARE coming), ack fast then process off the request thread. */
public final class WebhookReceiver {
// ---- signature verification (mirrors WebhookSender.HmacSigner) ----
static final class Verifier {
static String sign(String secret, String payload, long timestampEpochSeconds) {
try {
String signedPayload = timestampEpochSeconds + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(raw.length * 2);
for (byte b : raw) hex.append(String.format("%02x", b));
return hex.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** Constant-time compare -- do not use String.equals for secrets/signatures. */
static boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) return false;
int diff = 0;
for (int i = 0; i < a.length(); i++) diff |= a.charAt(i) ^ b.charAt(i);
return diff == 0;
}
/** Reject if the signature doesn't match OR the timestamp is outside the replay window. */
static boolean verify(String secret, String payload, long timestamp, String signature,
long nowEpochSeconds, long toleranceSeconds) {
String expected = sign(secret, payload, timestamp);
if (!constantTimeEquals(expected, signature)) return false; // tampered / wrong secret
long skew = Math.abs(nowEpochSeconds - timestamp);
return skew <= toleranceSeconds; // outside window = replay
}
}
/** BROKEN receiver: no dedup. Every delivery attempt that reaches here re-runs the side effect. */
static final class NaiveReceiver {
final AtomicInteger walletCreditedCents = new AtomicInteger(0);
void process(String eventId, int amountCents) {
walletCreditedCents.addAndGet(amountCents); // credits the wallet -- AGAIN, on every duplicate
}
}
/** FIXED receiver: dedup by event-id before the side effect runs -- idempotent under at-least-once. */
static final class SafeReceiver {
final AtomicInteger walletCreditedCents = new AtomicInteger(0);
final Set<String> seenEventIds = ConcurrentHashMap.newKeySet();
/** returns true if this call actually applied the side effect (first time seeing this event id). */
boolean process(String eventId, int amountCents) {
if (!seenEventIds.add(eventId)) return false; // Set.add returns false if already present
walletCreditedCents.addAndGet(amountCents);
return true;
}
}
/** The fast-2xx-then-async shape: ack immediately, hand the real work to a background executor. */
static final class AsyncReceiver {
final SafeReceiver downstream = new SafeReceiver();
final ExecutorService pool = Executors.newSingleThreadExecutor();
final AtomicInteger acked = new AtomicInteger(0);
final AtomicInteger processed = new AtomicInteger(0);
/** Returns the ack latency in nanoseconds -- this is what the sender's socket sees. */
long receive(final String eventId, final int amountCents, final long simulatedProcessingMs) {
long start = System.nanoTime();
acked.incrementAndGet(); // "200 OK, we have it" -- decided instantly
pool.submit(new Runnable() {
public void run() {
try { Thread.sleep(simulatedProcessingMs); } catch (InterruptedException ignored) {}
if (downstream.process(eventId, amountCents)) processed.incrementAndGet();
}
});
return System.nanoTime() - start; // the caller (sender) only waits for THIS
}
void shutdownAndAwait() throws InterruptedException {
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}
public static void main(String[] args) throws Exception {
String secret = "whsec_demo_shared_secret";
String payload = "{\"type\":\"payment.succeeded\",\"amount\":5000}";
long now = System.currentTimeMillis() / 1000L;
String sig = Verifier.sign(secret, payload, now);
System.out.println("=== 1. Signature + timestamp verification ===");
System.out.println(" fresh, correctly signed -> accept? " + Verifier.verify(secret, payload, now, sig, now, 300));
System.out.println(" tampered payload -> accept? " + Verifier.verify(secret, "{\"type\":\"payment.succeeded\",\"amount\":999999}", now, sig, now, 300));
long staleTs = now - 900; // 15 minutes old
String staleSig = Verifier.sign(secret, payload, staleTs);
System.out.println(" correct sig but 900s old -> accept? " + Verifier.verify(secret, payload, staleTs, staleSig, now, 300)
+ " (REPLAY rejected: outside the 300s tolerance window)");
System.out.println();
System.out.println("=== 2. BREAK IT (a): at-least-once WITHOUT dedup -- double side effect ===");
NaiveReceiver naive = new NaiveReceiver();
// Same event, id "evt_42", delivered TWICE -- e.g. the sender's ack was lost on the wire
// so it retried, or a worker crashed after processing but before acking the queue message.
naive.process("evt_42", 5000);
naive.process("evt_42", 5000); // duplicate delivery of the SAME event id
System.out.println(" wallet credited (naive, no dedup): " + naive.walletCreditedCents.get()
+ " cents <- BUG: customer paid once, wallet credited twice ($" + (naive.walletCreditedCents.get() / 100.0) + ")");
System.out.println();
System.out.println("=== 3. FIX: same duplicate delivery, WITH event-id dedup ===");
SafeReceiver safe = new SafeReceiver();
boolean first = safe.process("evt_42", 5000);
boolean second = safe.process("evt_42", 5000); // duplicate again
System.out.println(" first delivery applied? " + first);
System.out.println(" duplicate applied? " + second + " (skipped -- already seen this event id)");
System.out.println(" wallet credited (safe, with dedup): " + safe.walletCreditedCents.get()
+ " cents <- correct: exactly one credit despite two deliveries");
System.out.println();
System.out.println("=== 4. Fast-2xx-then-async: ack latency vs. real processing latency ===");
AsyncReceiver async = new AsyncReceiver();
long ackNanos = async.receive("evt_99", 1234, 200 /* simulated slow downstream work, ms */);
System.out.println(" ack returned to sender in " + (ackNanos / 1000) + " microseconds"
+ " (well under any reasonable webhook timeout)");
Thread.sleep(50);
System.out.println(" processed count immediately after ack: " + async.processed.get()
+ " (0 -- the 200ms of real work is still running in the background)");
async.shutdownAndAwait();
System.out.println(" processed count after the background job finishes: " + async.processed.get()
+ " (1 -- decoupled from the sender's retry clock entirely)");
}
}
Compiled and executed with javac/java (JDK 8) before publishing — zero warnings, zero errors.
Reference implementation — Go, sender (backoff + jitter + HMAC signing)
Same mechanism as the Java version, idiomatic Go: an interface for the subscriber, crypto/hmac for signing, math/rand for jitter.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"sync/atomic"
"time"
)
// Event is a single webhook event to deliver at least once.
type Event struct {
ID string
Payload string
}
// DeliveryResult is what the subscriber's endpoint returned.
type DeliveryResult struct {
StatusCode int
}
func (r DeliveryResult) isSuccess() bool { return r.StatusCode >= 200 && r.StatusCode < 300 }
// Subscriber stands in for an HTTP POST to the subscriber's URL.
type Subscriber interface {
Deliver(payload, eventID string, timestamp int64, signature string) DeliveryResult
}
// sign returns the hex HMAC-SHA256 of "timestamp.payload" -- Stripe's own scheme,
// binding the timestamp INTO the signed message so a captured request can't be replayed
// later with a forged fresh timestamp (the signature would no longer match).
func sign(secret, payload string, timestamp int64) string {
signedPayload := fmt.Sprintf("%d.%s", timestamp, payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
return hex.EncodeToString(mac.Sum(nil))
}
// DeliveryWorker retries with full-jitter exponential backoff, then gives up.
type DeliveryWorker struct {
Secret string
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
UseBackoff bool // false reproduces the retry-storm bug (Movement 5 / break-it b)
rng *rand.Rand
}
func NewDeliveryWorker(secret string, maxAttempts int, base, max time.Duration, useBackoff bool) *DeliveryWorker {
return &DeliveryWorker{
Secret: secret, MaxAttempts: maxAttempts, BaseDelay: base, MaxDelay: max,
UseBackoff: useBackoff, rng: rand.New(rand.NewSource(42)), // fixed seed: reproducible demo numbers
}
}
// backoffDelay implements AWS's "full jitter": random(0, min(maxDelay, base*2^(attempt-1))).
func (w *DeliveryWorker) backoffDelay(attempt int) time.Duration {
if !w.UseBackoff {
return 0
}
capped := w.BaseDelay * time.Duration(int64(1)<<uint(attempt-1))
if capped > w.MaxDelay {
capped = w.MaxDelay
}
return time.Duration(w.rng.Int63n(int64(capped) + 1))
}
// Deliver returns true if the event was accepted (2xx) within MaxAttempts.
func (w *DeliveryWorker) Deliver(e Event, sub Subscriber) bool {
for attempt := 1; attempt <= w.MaxAttempts; attempt++ {
ts := time.Now().Unix()
sig := sign(w.Secret, e.Payload, ts)
r := sub.Deliver(e.Payload, e.ID, ts, sig)
if r.isSuccess() {
fmt.Printf(" attempt %d -> %d OK, delivered\n", attempt, r.StatusCode)
return true
}
fmt.Printf(" attempt %d -> %d FAIL\n", attempt, r.StatusCode)
if attempt < w.MaxAttempts {
delay := w.backoffDelay(attempt)
fmt.Printf(" backing off %v before retry %d\n", delay, attempt+1)
time.Sleep(delay)
}
}
fmt.Printf(" GIVE UP after %d attempts -> endpoint AUTO-DISABLED\n", w.MaxAttempts)
return false
}
// flappingSubscriber fails the first failCount calls, then succeeds.
type flappingSubscriber struct {
calls int64
failCount int64
}
func (f *flappingSubscriber) Deliver(payload, eventID string, ts int64, sig string) DeliveryResult {
n := atomic.AddInt64(&f.calls, 1)
if n <= f.failCount {
return DeliveryResult{503}
}
return DeliveryResult{200}
}
// alwaysFailingSubscriber never recovers -- exercises give-up/auto-disable.
type alwaysFailingSubscriber struct{}
func (alwaysFailingSubscriber) Deliver(payload, eventID string, ts int64, sig string) DeliveryResult {
return DeliveryResult{500}
}
// instantFailSubscriber answers instantly with a failure -- used to measure request rate.
type instantFailSubscriber struct{ calls int64 }
func (s *instantFailSubscriber) Deliver(payload, eventID string, ts int64, sig string) DeliveryResult {
atomic.AddInt64(&s.calls, 1)
return DeliveryResult{500}
}
func main() {
secret := "whsec_demo_shared_secret"
fmt.Println("=== 1. Normal delivery, first attempt succeeds ===")
normal := NewDeliveryWorker(secret, 6, 100*time.Millisecond, 2*time.Second, true)
normal.Deliver(Event{"evt_1", `{"type":"payment.succeeded"}`}, &flappingSubscriber{failCount: 0})
fmt.Println()
fmt.Println("=== 2. Flapping endpoint: fails 3x, then recovers under backoff+jitter ===")
backoffWorker := NewDeliveryWorker(secret, 6, 100*time.Millisecond, 2*time.Second, true)
t0 := time.Now()
backoffWorker.Deliver(Event{"evt_2", `{"type":"invoice.paid"}`}, &flappingSubscriber{failCount: 3})
fmt.Printf(" wall time=%v (attempts spaced out by design)\n", time.Since(t0))
fmt.Println()
fmt.Println("=== 3. Endpoint never recovers: give up after maxAttempts, auto-disable ===")
giveUpWorker := NewDeliveryWorker(secret, 5, 50*time.Millisecond, 800*time.Millisecond, true)
ok := giveUpWorker.Deliver(Event{"evt_3", `{"type":"charge.failed"}`}, alwaysFailingSubscriber{})
fmt.Println(" delivered =", ok, "-> subscriber endpoint would be marked DISABLED in the DB")
fmt.Println()
fmt.Println("=== 4. BREAK IT (b): immediate retry (no backoff) vs backoff -- the retry storm ===")
window := 300 * time.Millisecond
stormTarget := &instantFailSubscriber{}
stormStart := time.Now()
for time.Since(stormStart) < window {
ts := time.Now().Unix()
sig := sign(secret, "{}", ts)
stormTarget.Deliver("{}", "evt_storm", ts, sig)
}
rate := float64(atomic.LoadInt64(&stormTarget.calls)) * float64(time.Second) / float64(window)
fmt.Printf(" NO-BACKOFF worker hammered the endpoint %d times in %v (~%.0f req/s)\n",
stormTarget.calls, window, rate)
politeTarget := &instantFailSubscriber{}
politeWorker := NewDeliveryWorker(secret, 1<<30, 50*time.Millisecond, 500*time.Millisecond, true)
politeStart := time.Now()
attempt := 0
for time.Since(politeStart) < window {
ts := time.Now().Unix()
sig := sign(secret, "{}", ts)
politeTarget.Deliver("{}", "evt_storm", ts, sig)
attempt++
time.Sleep(politeWorker.backoffDelay(attempt))
}
politeRate := float64(atomic.LoadInt64(&politeTarget.calls)) * float64(time.Second) / float64(window)
fmt.Printf(" BACKOFF worker made only %d request(s) to the same endpoint in the same %v window (~%.0f req/s)\n",
politeTarget.calls, window, politeRate)
fmt.Println(" Same failing endpoint, same wall-clock window -- backoff is the only difference,",
"and it is the difference between a request storm and a handful of polite retries.")
}
Builds clean with go build ./... and go vet ./...; exercised end to end before publishing.
Reference implementation — Go, receiver (verify + dedup + fast-ack-then-async)
The async path uses a real goroutine plus a sync.WaitGroup so the demo can prove the ack returns before the background work finishes — exercised under go test -race with zero races reported.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"
)
// verifySign mirrors the sender's sign() -- same construction, "timestamp.payload".
func verifySign(secret, payload string, timestamp int64) string {
signedPayload := fmt.Sprintf("%d.%s", timestamp, payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
return hex.EncodeToString(mac.Sum(nil))
}
// verify rejects a tampered payload OR a signature whose timestamp has aged out of the
// replay window -- an attacker who captured a valid request can't replay it after tolerance.
func verify(secret, payload string, timestamp int64, signature string, now int64, toleranceSeconds int64) bool {
expected := verifySign(secret, payload, timestamp)
if hmac.Equal([]byte(expected), []byte(signature)) == false { // constant-time compare
return false
}
skew := now - timestamp
if skew < 0 {
skew = -skew
}
return skew <= toleranceSeconds
}
// NaiveReceiver has NO dedup -- every delivery attempt re-runs the side effect.
type NaiveReceiver struct {
mu sync.Mutex
walletCreditedCents int
}
func (r *NaiveReceiver) Process(eventID string, amountCents int) {
r.mu.Lock()
defer r.mu.Unlock()
r.walletCreditedCents += amountCents
}
// SafeReceiver dedups by event id BEFORE the side effect runs -- idempotent under at-least-once.
type SafeReceiver struct {
mu sync.Mutex
seen map[string]bool
walletCreditedCents int
}
func NewSafeReceiver() *SafeReceiver {
return &SafeReceiver{seen: make(map[string]bool)}
}
// Process returns true only if this call actually applied the side effect.
func (r *SafeReceiver) Process(eventID string, amountCents int) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.seen[eventID] {
return false
}
r.seen[eventID] = true
r.walletCreditedCents += amountCents
return true
}
// AsyncReceiver acks instantly and hands the real work to a goroutine -- fast-2xx-then-async.
type AsyncReceiver struct {
downstream *SafeReceiver
mu sync.Mutex
processed int
wg sync.WaitGroup
}
func NewAsyncReceiver() *AsyncReceiver {
return &AsyncReceiver{downstream: NewSafeReceiver()}
}
// Receive returns the ack latency; the real processing happens on a separate goroutine,
// so the sender's socket is released long before the downstream work finishes.
func (a *AsyncReceiver) Receive(eventID string, amountCents int, simulatedProcessing time.Duration) time.Duration {
start := time.Now()
a.wg.Add(1)
go func() {
defer a.wg.Done()
time.Sleep(simulatedProcessing)
if a.downstream.Process(eventID, amountCents) {
a.mu.Lock()
a.processed++
a.mu.Unlock()
}
}()
return time.Since(start) // the 200 OK goes back to the sender NOW, not after Sleep
}
func (a *AsyncReceiver) Processed() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.processed
}
func (a *AsyncReceiver) Wait() { a.wg.Wait() }
func main() {
secret := "whsec_demo_shared_secret"
payload := `{"type":"payment.succeeded","amount":5000}`
now := time.Now().Unix()
sig := verifySign(secret, payload, now)
fmt.Println("=== 1. Signature + timestamp verification ===")
fmt.Println(" fresh, correctly signed -> accept?", verify(secret, payload, now, sig, now, 300))
tampered := `{"type":"payment.succeeded","amount":999999}`
fmt.Println(" tampered payload -> accept?", verify(secret, tampered, now, sig, now, 300))
staleTs := now - 900
staleSig := verifySign(secret, payload, staleTs)
fmt.Println(" correct sig but 900s old -> accept?", verify(secret, payload, staleTs, staleSig, now, 300),
" (REPLAY rejected: outside the 300s tolerance window)")
fmt.Println()
fmt.Println("=== 2. BREAK IT (a): at-least-once WITHOUT dedup -- double side effect ===")
naive := &NaiveReceiver{}
naive.Process("evt_42", 5000)
naive.Process("evt_42", 5000) // duplicate delivery of the SAME event id
fmt.Printf(" wallet credited (naive, no dedup): %d cents <- BUG: customer paid once, wallet credited twice ($%.2f)\n",
naive.walletCreditedCents, float64(naive.walletCreditedCents)/100.0)
fmt.Println()
fmt.Println("=== 3. FIX: same duplicate delivery, WITH event-id dedup ===")
safe := NewSafeReceiver()
first := safe.Process("evt_42", 5000)
second := safe.Process("evt_42", 5000)
fmt.Println(" first delivery applied? ", first)
fmt.Println(" duplicate applied? ", second, " (skipped -- already seen this event id)")
fmt.Printf(" wallet credited (safe, with dedup): %d cents <- correct: exactly one credit despite two deliveries\n",
safe.walletCreditedCents)
fmt.Println()
fmt.Println("=== 4. Fast-2xx-then-async: ack latency vs. real processing latency ===")
async := NewAsyncReceiver()
ackLatency := async.Receive("evt_99", 1234, 200*time.Millisecond)
fmt.Printf(" ack returned to sender in %v (well under any reasonable webhook timeout)\n", ackLatency)
time.Sleep(50 * time.Millisecond)
fmt.Println(" processed count immediately after ack:", async.Processed(),
" (0 -- the 200ms of real work is still running in the background)")
async.Wait()
fmt.Println(" processed count after the background job finishes:", async.Processed(),
" (1 -- decoupled from the sender's retry clock entirely)")
}
Builds clean with go build ./... and go vet ./...; also run under go run -race and go test -race with zero data races reported (the async path is the only concurrent code here, guarded by a mutex around processed and the dedup map).
5. Break it — the tests that fail
Break-it (a) — delete the dedup check, watch the side effect double. Delete exactly one line from SafeReceiver.process: the if (!seenEventIds.add(eventId)) return false; guard (Java) / the if r.seen[eventID] { return false } guard (Go). That's the NaiveReceiver in the reference code above — nothing hypothetical, it's a real class in the page's own source, run directly:
=== 2. BREAK IT (a): at-least-once WITHOUT dedup -- double side effect ===
wallet credited (naive, no dedup): 10000 cents <- BUG: customer paid once, wallet credited twice ($100.00)
=== 3. FIX: same duplicate delivery, WITH event-id dedup ===
first delivery applied? true
duplicate applied? false (skipped -- already seen this event id)
wallet credited (safe, with dedup): 5000 cents <- correct: exactly one credit despite two deliveries
Read those numbers carefully: the exact same two calls — process("evt_42", 5000) twice — produce 10000 cents credited with no dedup and 5000 cents credited with it. Nothing about the delivery changed; the receiver's handling of a duplicate is the entire difference between a correct ledger and a customer support ticket about a double-refund. This is precisely why "at-least-once" is a contract on both sides: the sender promises "you'll get it at least once," and that promise is only safe to accept if the receiver promises back "delivering it more than once is a no-op."
Break-it (b) — delete the backoff, watch the retry storm. Set useBackoff=false (Java) / UseBackoff: false (Go) on the DeliveryWorker — every retry then fires with zero delay, exactly the "attempt 1 — retry until it succeeds, immediately" design from Movement 3. Point it and a backoff-enabled worker at the identical instantFailSubscriber, for the identical 300ms wall-clock window, straight from a real run of the page's own Java code:
=== 4. BREAK IT (b): immediate retry (no backoff) vs backoff -- the retry storm ===
NO-BACKOFF worker hammered the endpoint 2860 times in 300ms (~9533 req/s)
BACKOFF worker made only 4 request(s) to the same endpoint in the same 300ms window (~13 req/s)
And the Go build of the exact same scenario, same 300ms window (Go's tighter loop overhead makes the gap even starker):
=== 4. BREAK IT (b): immediate retry (no backoff) vs backoff -- the retry storm ===
NO-BACKOFF worker hammered the endpoint 630922 times in 300ms (~2081950 req/s)
BACKOFF worker made only 5 request(s) to the same endpoint in the same 300ms window (~17 req/s)
The exact counts vary run to run (they're wall-clock-timed, and depend on the machine's spin speed) — re-running either binary a few times during authoring produced a range of roughly 2,600–3,200 requests for Java and 600,000–640,000 for Go in the no-backoff case, against a steady 4–5 requests in the backoff case every time. The number that matters isn't the exact count, it's the three-to-five-orders-of-magnitude gap between "no backoff" and "backoff," reproduced identically in both languages: one design politely checks in a handful of times over a fraction of a second; the other turns your own retry logic into a denial-of-service attack against a subscriber that is already having a bad day.
6. Optimise — with trade-offs
| Design | What it guarantees | What it costs | Use when |
|---|---|---|---|
| At-most-once (fire once, never retry) | Simple; no duplicate side effects, ever | Silently drops events on any transient failure — unacceptable for "your payment succeeded" | Non-critical, best-effort notifications only (e.g. an analytics ping) where a lost event is a shrug, not an incident |
| At-least-once + receiver dedup (this lab) | No silent drops; duplicates are expected and neutralized by the receiver | Receiver MUST maintain a dedup store (event-id set/table) and MUST design side effects to be safely re-runnable behind it | Anything where losing an event is worse than occasionally re-checking one you've already seen — the default for payments, order state, most real business events |
| "Exactly-once" (the myth) | What everyone wishes existed: delivered once, processed once, no coordination needed | Not achievable end-to-end across an untrusted network boundary you don't control — see Idempotency & "Exactly-Once Is a Myth". What people call "exactly-once" in practice IS at-least-once delivery plus idempotent processing — this lab's actual design, just renamed | Never build for this directly; build at-least-once + dedup and call the composite "effectively exactly-once" if you must use the phrase at all |
| Sync-in-request delivery | Simplest code — no queue, no worker, no eventual consistency to reason about | Couples your critical path's latency (and availability) to a third party's uptime — one slow subscriber degrades every request that triggers a webhook | Internal calls between services you operate, where you control both ends' SLAs — never for third-party subscriber URLs |
| Queue + worker delivery (this lab) | The triggering request returns immediately regardless of subscriber health; retries are isolated to the worker | An extra moving part (a durable queue) and eventual — not immediate — delivery to the subscriber | Any webhook to a URL you don't operate and can't guarantee the uptime of, which in practice is every real webhook system |
| Sign + verify (HMAC + timestamp) | Receiver can prove the request came from you and isn't a stale replay, at negligible per-request cost | Requires safe secret distribution/rotation per subscriber, and the receiver must implement constant-time comparison correctly | The default for any public webhook endpoint — cheap, stateless per request, no extra network hop |
| mTLS between sender and receiver | Transport-level mutual authentication; no per-message signing logic needed | Both sides must provision, rotate, and validate certificates — heavier operational burden than a shared secret, and most merchants' infra isn't set up to terminate client-cert TLS | Closed, high-trust B2B integrations where both parties already run a PKI (common inside a company's own service mesh; rare for arbitrary third-party webhook subscribers) |
The real judgment call: don't reach for "exactly-once" semantics, distributed transactions, or mTLS by default — at-least-once + idempotent receiver + HMAC-with-timestamp is the boring, well-trodden design that Stripe, Razorpay, and effectively every payments platform actually ship, because it's the cheapest design that is still correct under network failure. See Idempotency Keys and How to Implement Them Safely for Payments for the same pattern applied to the payment-initiation side (the client's POST /charge), which is the mirror image of this lab's receiver-side dedup.
7. Defend under drilling
- "At-least-once vs exactly-once — which do you build, and why?" At-least-once, always, for anything crossing a network boundary you don't fully control — because true exactly-once delivery requires the sender and receiver to share one atomic transaction across that boundary, which they can't. What's actually achievable, and what this lab builds, is at-least-once delivery PLUS an idempotent receiver, which behaves like exactly-once from the outside (each event's effect happens once) without needing the impossible atomic guarantee.
- "How does the receiver dedup, concretely — walk me through the data structure and its failure modes." A durable store keyed by event id (not by delivery attempt — the id must be stable across every retry of the same logical event): a database unique constraint on
event_id, or a fast lookup table with a TTL a little longer than the sender's total retry window. The check-and-mark must happen atomically with (or before, in the same transaction as) the side effect — checking dedup, THEN separately crediting the wallet, THEN separately marking it seen has its own race: two concurrent deliveries of the same duplicate can both pass the check before either marks it seen. Use a single atomic operation (an INSERT with a unique constraint that fails on the second attempt, or, as in this lab's code, a concurrent set whoseadd()atomically reports whether the value was new). - "Why sign AND timestamp — why isn't the signature alone enough?" A signature alone proves the payload wasn't tampered with and came from someone holding the secret — but it says nothing about WHEN. An attacker who intercepts one legitimate, correctly-signed request can replay that exact byte-for-byte request an hour, a day, or a year later, and a signature-only check would accept it every time, because the signature is still mathematically valid. Binding the timestamp into the signed bytes (not just attaching it unsigned alongside) means the receiver can reject anything outside a tolerance window (Stripe/most implementations use ~5 minutes) — and because the timestamp is part of what's signed, an attacker can't just swap in a fresh timestamp without invalidating the signature.
- "How do you avoid the retry storm / thundering herd?" Two mechanisms, and they solve different halves of the problem. Exponential backoff (with a cap) stops any single delivery from retrying at full speed forever. Jitter — randomizing the actual delay within
[0, computed_delay]— stops many deliveries that all started failing at once (a batch of events queued for the same down subscriber) from retrying in synchronized lockstep bursts. Backoff alone still produces periodic coordinated spikes; jitter is what smooths those spikes into a steady trickle. Movement 5's numbers show what happens with neither: three to five orders of magnitude more requests than the endpoint (or your outbound capacity) should ever see. - "What happens if the subscriber's endpoint is down for a genuinely long time — days, not minutes?" You do not retry forever — that's an unbounded backlog problem waiting to happen (queue depth for that one dead subscriber grows without limit, and eventually contends with capacity meant for healthy subscribers). Cap the retry window (Stripe: ~3 days; Razorpay: ~24 hours), and once an endpoint has failed continuously past a threshold, auto-disable it and surface that to the merchant out-of-band (dashboard alert, email) — don't let one dead URL silently consume delivery capacity indefinitely.
- "How does this scale at 100× the event volume — what breaks?" A single delivery worker pulling from a single queue serializes all deliveries to all subscribers behind one drain rate — one very slow or very failure-prone subscriber can starve delivery to every OTHER healthy subscriber sharing that worker. The fix is partitioning: shard the queue/worker pool by subscriber (or subscriber-id hash), so one flapping endpoint's retry backlog can't block deliveries meant for a different, healthy endpoint — the same "don't let one bad actor own the shared resource" logic that shows up in rate limiting and connection pooling.
8. You can now defend
- You can build a webhook delivery worker from scratch in Java and Go with exponential backoff + full jitter, and explain — with the retry-storm numbers to back it up — exactly why both pieces (backoff AND jitter) are required, not either alone.
- You can sign a webhook payload with HMAC-SHA256 bound to a timestamp, and explain precisely why the timestamp must be inside the signed bytes rather than a bare unsigned header, and why that's what stops replay attacks a signature-only scheme would still accept.
- You've broken both halves of this system on purpose — deleting the receiver's dedup check to reproduce a real double-charge, and deleting the sender's backoff to reproduce a real retry storm with measured request counts — so you know these aren't abstract risks, they're one-line regressions away in either direction.
- You can defend at-least-once-plus-idempotent-receiver as the correct design against "why not just make it exactly-once," and place it on a trade-off table against at-most-once, sync-in-request delivery, and mTLS, arguing concretely what each buys and what it costs.
Re-authored/Deepened for this guide. Reference code compiled and executed (javac/java on JDK 8; go build, go vet, go run -race, go test -race) before publishing; both break-it scenarios reproduce deterministically in direction (dedup: 2× the credit; no-backoff: three-to-five orders of magnitude more requests) on every run. Mechanics modeled on Stripe's and Razorpay's own published webhook documentation (retry schedule, HMAC-SHA256 signing with timestamp, event-id-based dedup, auto-disable after sustained failure). See also: Polling vs LongPolling vs WebSockets vs Webhooks, The Architecture of the Retry Pattern, Idempotency & "Exactly-Once Is a Myth", and Idempotency Keys and How to Implement Them Safely for Payments.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Webhook Delivery System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Webhook Delivery System** (Hands-On Builds) and want to truly understand it. Explain Design a Webhook Delivery System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Webhook Delivery System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Webhook Delivery System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Webhook Delivery System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.