System design · Payments · Part 3

Webhooks

How a payment service learns the result when a response is lost: webhooks, eventual consistency, duplicate events and signature checks.

Lost responses

The payment service asks Stripe to charge $100. Stripe processes it successfully, but the response disappears on the way back:

  1. Payment service

  2. charge $100

    Stripe

    Succeeded

  3. ✕ response lost

    Payment service

    Still PROCESSING

Stripe has charged the card, but the payment service never hears about it.

The payment stays PROCESSING in the database. How does the payment service eventually learn that the payment succeeded? One powerful answer is a webhook.

What is a webhook

A webhook is another server calling this server when something happens. Normally, the payment service calls Stripe. A webhook reverses the direction:

A normal request

  1. Payment service

  2. Stripe

A webhook

  1. Stripe

  2. Payment service

To say a payment succeeded, Stripe sends a request to an endpoint like POST /webhooks/stripe, with an event that conceptually looks like this:

POST /webhooks/stripe
{
  "type": "payment.succeeded",
  "paymentId": "pi_abc123"
}

Even when the original response is lost, Stripe can report the result separately.

Why webhooks matter

Replay the failure from before:

  1. The payment service calls Stripe.
  2. Stripe charges the customer.
  3. The response gets lost.
  4. The payment stays PROCESSING.

A few seconds later, the webhook arrives:

  1. Stripewebhook

  2. Payment service

  3. pi_abc123 succeeded

    Update database

  4. SUCCEEDED

The webhook carries the result the lost response would have.

The system becomes correct, eventually. This is called eventual consistency.

Eventual consistency

Systems may temporarily disagree, but eventually they converge to the correct state.

Before the webhook

StripeSUCCEEDED
Payment DBPROCESSING

For a moment, the two sides disagree.

After the webhook

StripeSUCCEEDED
Payment DBSUCCEEDED

The webhook brings them back in line.

Payment systems use this idea often, because external services and networks are not perfectly reliable.

Webhooks arrive more than once

Payment providers usually retry webhooks. Imagine Stripe sends payment.succeeded, but the payment service is temporarily down. Stripe doesn't know whether the event was handled, so it sends it again, and maybe again:

  • Webhook 1: evt_123
  • Webhook 2: evt_123
  • Webhook 3: evt_123
  • Webhook 4: evt_123

Processed once

The same event, four deliveries

So webhook processing must be idempotent too. The same concept keeps appearing.

1.Webhook idempotency

Each event from the provider usually has an ID, like evt_123. The payment service records every event it has handled:

webhook_eventsMeaning
event_idThe provider's ID for the event
providerStripe
processed_atWhen it was handled

When an event arrives, the first question is: has evt_123 already been processed?

New event

  1. Webhook

    evt_123

  2. Database

    Processed? No

  3. Process it

Same event again

  1. Webhook

    evt_123

  2. Database

    Processed? Yes

  3. Ignore duplicate

A repeated event changes nothing.

Again, a database unique constraint closes the race between two deliveries arriving at once:

SQL
UNIQUE (provider, event_id)

Is the webhook really from Stripe?

The webhook endpoint is a public URL. Without a check, an attacker could send this and make an unpaid order appear paid:

A forged request
POST /webhooks/stripe

{ "type": "payment.succeeded" }

So providers typically sign webhook requests. Stripe sends the event body with a signature, and the payment service verifies the signature using a secret shared with Stripe. Only after verification is the event trusted.

  1. Stripe

    Event body + signature

  2. Webhook endpoint

    Verifies with the secret

Always

  1. Receive webhook

  2. Verify signature

  3. Check duplicate event

  4. Process event

  5. Update payment

Never

  1. Receive webhook

  2. Blindly trust it

Every webhook goes through the same steps, in this order.

The updated architecture

The architecture is becoming realistic:

  1. Customer

  2. Frontend

  3. HTTPS

    Payment service

  • save

    PostgreSQL

  • process
    1. Stripe

    2. webhook

      Webhook endpoint

    3. PostgreSQL

Stripe now reports results back through the webhook endpoint.