Design an Event and Webhook Platform

Updated · By

Write each event in the same database transaction as the state change, an outbox, so events exist exactly when changes commit. A relay publishes them to a log; a dispatcher matches subscriptions and delivers to each endpoint from its own queue, signed and retried with backoff. Keep events 30 days so merchants can replay what they missed.

hardPattern: Message queues and async workFrom Devana's Stripe question set

Problem

Design the system that turns internal state changes, such as a payment succeeding or a refund being issued, into events that merchants subscribe to and receive at their own HTTPS endpoints.

Cover event generation, subscription filtering, delivery, how a merchant can tell an event really came from you, and how a merchant recovers after their endpoint was down.

POST /webhook_endpoints              {url, event_types[]}  -> {id, signing_secret}
GET  /events?type=&created_after=    -> a page of events
POST /webhook_endpoints/{id}/replay  {from, to}  -> {replay_id}
Delivery: POST to the merchant's URL with a signature header "t=<unix time>,v1=<hex HMAC>"

This is one of Devana's own questions, written in the style of Stripe's interviews. It is not a question leaked from Stripe, and Devana is not affiliated with Stripe.

Requirements

  • 10^6 events per minute.
  • Merchants subscribe to specific event types.
  • Delivery is at least once, so merchants must tolerate duplicates.
  • Events are retained and replayable for 30 days.
  • A slow or failing merchant endpoint must not delay anyone else's events.

Scenarios

Scenario 1

A payment's state changes and its transaction commits. Expected: Exactly one event is created, and each subscribed endpoint receives it at least once.

The event row is written in the same transaction as the payment, so a rollback leaves no event and a commit always leaves one.

Scenario 2

A merchant's endpoint is down for six hours. Expected: Deliveries retry with backoff, and after recovery the merchant replays the gap.

Retries continue for a bounded period, and the events API lets the merchant list or replay anything from the last 30 days.

Hints

Open one at a time, and try the problem again after each.

Hint 1

If you publish an event after committing the transaction, what happens when the process crashes in between? And if you publish before?

Hint 2

Why must one merchant's slow endpoint not hold up another merchant's events?

Hint 3

How does a merchant tell a real event from a forged request to the same URL?

Hint 4

At least once means duplicates. What do you give merchants to deduplicate with?

A worked answer

Requirements and estimates

10^6 events a minute is about 17,000 a second on average, more at peaks, and each event can go to several endpoints. Thirty days of retention at that rate is about 4.3 × 10^10 events, so storage is partitioned by time and merchant.

Events exactly when changes commit

Publishing to a queue after the commit loses the event if the process crashes in between; publishing before the commit can announce a change that then rolls back. The outbox pattern fixes both: the service writes the event to an outbox table in the same transaction as the change.

A relay reads committed outbox rows, or the database's change log, and publishes them to a durable log partitioned by merchant, marking rows as sent. A crash can make it publish a row twice, which at-least-once delivery already allows.

Subscriptions and fan-out

A dispatcher consumes the log, looks up the merchant's endpoints subscribed to that event type, cached because subscriptions change rarely, and creates one delivery task per endpoint.

Delivery and isolation

Each endpoint gets its own queue, so a slow endpoint only backs up itself. A worker sends the signed payload with a short timeout. A 2xx response marks the delivery done; anything else schedules a retry with exponential backoff and jitter, up to a cap. An endpoint that fails for days is disabled and its owner told.

Verification and replay

Each endpoint has its own signing secret. The signature is an HMAC of the timestamp and the raw body; merchants recompute it and reject old timestamps, so a captured request cannot be replayed later. Every event has a stable id for deduplication.

Replay re-enqueues stored events for an endpoint and a time range, and the list API lets merchants pull events themselves instead.

How to say it in the interview

Start with the dual write problem, because the question asks for events "if and only if the state change committed": name the crash windows on both sides, then the outbox. Then isolation per endpoint, and only then retries and signatures. Say plainly that delivery is at least once and what the merchant does about duplicates.

The core in code

The part merchants build against, so it has to be exactly right: signing a delivery and verifying it. The signature covers the timestamp and the raw body, and verification rejects stale timestamps so a captured request cannot be replayed later.

Python

import hashlib
import hmac

def sign(secret, timestamp, payload):
    message = f"{timestamp}.{payload}".encode()
    return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()

def signature_header(secret, timestamp, payload):
    return f"t={timestamp},v1={sign(secret, timestamp, payload)}"

def verify(secret, header, payload, now, tolerance=300):
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(now - timestamp) > tolerance:            # stale: possibly a replay
        return False
    expected = sign(secret, timestamp, payload)
    return hmac.compare_digest(expected, parts.get("v1", ""))   # constant time

JavaScript (Node.js)

const crypto = require("node:crypto");

function sign(secret, timestamp, payload) {
  return crypto.createHmac("sha256", secret).update(timestamp + "." + payload).digest("hex");
}

function signatureHeader(secret, timestamp, payload) {
  return "t=" + timestamp + ",v1=" + sign(secret, timestamp, payload);
}

function verify(secret, header, payload, now, tolerance = 300) {
  const parts = {};
  for (const item of header.split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i)] = item.slice(i + 1);
  }
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  if (Math.abs(now - timestamp) > tolerance) return false; // stale: possibly a replay
  const expected = Buffer.from(sign(secret, timestamp, payload));
  const given = Buffer.from(parts.v1);
  return expected.length === given.length && crypto.timingSafeEqual(expected, given); // constant time
}

Both versions are tested against the same cases, including every example on this page.

  • The signature is over the raw body exactly as sent. A merchant who parses the JSON and serializes it again before verifying gets a different string and a failed check.
  • Comparing with == can leak, through timing, how many characters matched; compare_digest and timingSafeEqual take the same time either way.
  • A five-minute tolerance limits replays without rejecting deliveries delayed by ordinary clock skew.

Follow-up questions

How do you guarantee an event is emitted if and only if the state change committed?

The outbox: the event row commits or rolls back together with the change, and the relay publishes only committed rows. A relay crash can publish a row twice, which the event id makes harmless.

How does a merchant verify an event genuinely came from you?

By recomputing the HMAC with the endpoint's secret, comparing in constant time, and rejecting timestamps outside the tolerance. Allow two active secrets during rotation so merchants can switch without dropping events.

How does a merchant replay events missed during their outage?

Through the events API: list events by type and time range, or request a replay to an endpoint, which re-enqueues them as new deliveries of the same event ids.

Do you guarantee ordering? What does guaranteeing it cost?

Strict ordering per endpoint means one delivery in flight at a time, so a single slow response stalls everything behind it. A common choice is not to promise ordering and to include the object's current state or a version number in each event, so merchants can ignore stale updates.

Common mistakes

  • Publishing to the queue after the commit, with no plan for a crash in between.
  • One shared delivery queue, so a single slow merchant delays everyone.
  • Retrying immediately and indefinitely, which turns a merchant's outage into a flood when they recover.
  • Signing a parsed and re-serialized body instead of the exact bytes sent.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with a whiteboard he can read.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview