The synchronous path looks clean on paper. User clicks checkout, your platform fires a charge request, the provider confirms or declines, you show the result. Done.
But distributed systems don’t cooperate with clean diagrams.
The connection drops mid-request. A silent timeout swallows the response. The charge went through on the provider’s side — but your platform never received the confirmation. The user is staring at a spinner. You have no idea whether to retry or give up.
This is the gap webhooks are designed to close.
Table of contents
Open Table of contents
- The Happy Path vs. The Real World
- What Is a Webhook?
- The Setup: Endpoint, Events, Secret Key
- Signature Validation: The Security Layer
- At-Least-Once Delivery: The Catch
- Deduplication: Protecting Your System at the Receiving End
- Two Layers, Two Different Problems
- Responding Quickly, Processing Asynchronously
- The Mental Model
The Happy Path vs. The Real World
Let’s make the problem concrete. In the synchronous flow:
┌──────────┐ checkout ┌──────────────┐ charge req ┌──────────────┐
│ User │ ────────────▶ │ Platform │ ─────────────▶ │ Provider │
│ │ │ │ │ │
│ │ │ │ ◀───────────── │ confirmed │
│ │ ◀──────────── │ show result │ │ │
└──────────┘ └──────────────┘ └──────────────┘
That works when every hop succeeds. But what happens here:
┌──────────┐ checkout ┌──────────────┐ charge req ┌──────────────┐
│ User │ ────────────▶ │ Platform │ ─────────────▶ │ Provider │
│ │ │ │ │ │
│ spinner │ │ timeout! │ ✗ dropped ✗ │ ✓ charged │
│ │ │ now what? │ │ │
└──────────┘ └──────────────┘ └──────────────┘
The provider processed the charge. Your platform has no record of it. Retry and you might double-charge. Do nothing and the order stays in limbo. Neither is acceptable.
Webhooks are the fix.
What Is a Webhook?
A webhook is an HTTP callback — a POST request the provider sends to you when something happens on their end.
Instead of your platform polling the provider every few seconds asking “Did that payment go through?”, the provider knocks on your door the moment the event fires. Charge created. Payment failed. Subscription cancelled. Refund issued.
┌──────── webhook ────────┐
│ │
▼ │
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ Platform │ │ Provider │
│ │ │ │ │ │
│ │ │ POST /webhooks/stripe │ │
│ │ │ ◀───────────────────── │ event fired │
└──────────┘ └──────────────┘ └──────────────┘
You give them a door. They knock when it matters.
This shifts the delivery model from pull (you ask) to push (they tell). It’s more efficient, more timely, and — when implemented correctly — more reliable.
The Setup: Endpoint, Events, Secret Key
Before a provider can send you anything, three things need to be configured:
1. A webhook endpoint — a POST route in your application dedicated to receiving provider events.
# config/routes.rb
post "/webhooks/stripe", to: "webhooks/stripe#receive"
2. The events you care about — you register which triggers should call your system. You don’t need every event Stripe can fire, just the ones relevant to your domain: payment_intent.succeeded, payment_intent.payment_failed, customer.subscription.deleted, and so on.
3. A secret key — generated by the provider, scoped specifically to your webhook integration. This is the security layer. Every request the provider sends is signed using that secret.
Signature Validation: The Security Layer
This is the part most developers underestimate. Your webhook endpoint is a public POST route — anyone on the internet can send a request to it. Without validation, an attacker could send a fake “payment succeeded” event and your system would process it as legitimate.
The provider signs every webhook payload using HMAC-SHA256 with your secret key. Your endpoint must verify that signature on every single request before touching the payload.
Provider sends:
POST /webhooks/stripe
Headers:
Stripe-Signature: t=1713175200,v1=abc123...
Body:
{ "type": "payment_intent.succeeded", ... }
Your endpoint:
1. Extract timestamp + signature from header
2. Reconstruct the signed payload: "#{timestamp}.#{raw_body}"
3. Compute HMAC-SHA256 with your secret key
4. Compare to the signature in the header
5. If mismatch → 401. Reject and log.
6. If match → trust it and process.
In Rails, this looks like:
# app/controllers/webhooks/stripe_controller.rb
class Webhooks::StripeController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_stripe_signature
def receive
event_type = params[:type]
payload = params[:data][:object]
case event_type
when "payment_intent.succeeded"
PaymentSucceededHandler.call(payload)
when "payment_intent.payment_failed"
PaymentFailedHandler.call(payload)
end
head :ok
end
private
def verify_stripe_signature
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
secret = Rails.application.credentials.stripe[:webhook_secret]
begin
Stripe::Webhook.construct_event(payload, sig_header, secret)
rescue Stripe::SignatureVerificationError
Rails.logger.warn "Invalid Stripe signature — rejected"
head :unauthorized and return
end
end
end
Two rules:
- Invalid signature → 401. Reject and log. Do not process.
- Valid signature → trust it and act.
Also notice skip_before_action :verify_authenticity_token — Rails’ CSRF protection expects a session token, which webhooks don’t have. The HMAC signature is the equivalent protection here.
At-Least-Once Delivery: The Catch
Here’s something providers like Stripe are explicit about: they guarantee at-least-once delivery, not exactly-once.
What does that mean in practice? Stripe will retry webhook delivery if your endpoint doesn’t respond with a 2xx within a reasonable window. It spaces retries out over time and marks the event as failed after a certain number of attempts.
But even without failed retries, the same event can legitimately arrive more than once. Stripe’s delivery model leans toward durability over uniqueness. Network conditions, infrastructure hiccups, and edge cases in distributed systems mean duplicates are a real possibility — not just a theoretical one.
If your handler isn’t idempotent, a duplicate event can cause real damage: a double-processed order, a double-credited account, a double-sent confirmation email.
Deduplication: Protecting Your System at the Receiving End
The fix is straightforward: store the provider’s event ID in a processed_events table. Before handling any webhook, check whether you’ve already seen it.
# db/migrate/YYYYMMDDHHMMSS_create_processed_webhook_events.rb
class CreateProcessedWebhookEvents < ActiveRecord::Migration[8.0]
def change
create_table :processed_webhook_events do |t|
t.string :provider_event_id, null: false
t.string :provider, null: false
t.timestamps
end
add_index :processed_webhook_events, [:provider, :provider_event_id], unique: true
end
end
# app/models/processed_webhook_event.rb
class ProcessedWebhookEvent < ApplicationRecord
end
Now in the handler:
def receive
event_id = params[:id] # e.g. "evt_1Abc123..."
event_type = params[:type]
payload = params[:data][:object]
# Deduplicate: if we've seen this event, return 200 and move on
return head :ok if ProcessedWebhookEvent.exists?(
provider: "stripe",
provider_event_id: event_id
)
ActiveRecord::Base.transaction do
case event_type
when "payment_intent.succeeded"
PaymentSucceededHandler.call(payload)
when "payment_intent.payment_failed"
PaymentFailedHandler.call(payload)
end
ProcessedWebhookEvent.create!(
provider: "stripe",
provider_event_id: event_id
)
end
head :ok
end
The unique index on (provider, provider_event_id) is the hard guarantee. Even under concurrent delivery of the same event, the database constraint ensures only one write lands. The exists? check before is a fast path to avoid the transaction overhead on the common case.
Two Layers, Two Different Problems
It’s worth separating two concepts that often get conflated:
Idempotency-Key (outbound): When your platform sends a charge request to Stripe, you include an Idempotency-Key header. This tells Stripe: “If you receive this same request twice due to a timeout or retry on my end, don’t charge twice.” It protects Stripe from double-charging caused by your retries.
Event deduplication (inbound): When Stripe sends a webhook to your platform, you store the event ID and check it before processing. This protects your system from double-processing caused by Stripe’s at-least-once delivery.
Your platform ──Idempotency-Key──▶ Stripe (prevents double-charge)
Your platform ◀──event-id check─── Stripe (prevents double-process)
Two different layers. Both matter. Neither covers for the absence of the other.
Responding Quickly, Processing Asynchronously
One operational detail worth knowing: your webhook endpoint should respond with 200 OK as fast as possible. If processing takes too long, the provider may time out and treat the delivery as failed — triggering a retry for an event you already received and partially processed.
The pattern is to acknowledge immediately and hand off to a background job:
def receive
return head :ok if ProcessedWebhookEvent.exists?(
provider: "stripe",
provider_event_id: params[:id]
)
# Enqueue for async processing — respond immediately
StripeWebhookJob.perform_later(
event_id: params[:id],
event_type: params[:type],
payload: params[:data][:object].to_json
)
head :ok
end
The job handles the actual processing and marks the event as processed. If the job fails, your background queue retries it — but the event ID check inside the job ensures you don’t double-process even if the same webhook arrives again before the job completes.
The Mental Model
Webhooks aren’t just a notification mechanism.
They’re a contract between you and the provider: “Here’s a secure backdoor. I’ll use it to keep your state consistent with mine, even when the synchronous flow fails you.”
The synchronous flow is the happy path. Webhooks are the recovery mechanism. Together, they handle both the case where things go right and the case where they don’t — which in distributed systems is the only design worth shipping.
Synchronous flow: Platform ──▶ Provider ──▶ Platform (instant, best case)
Webhook flow: Provider ──▶ Platform (async, guaranteed)
Get the signature validation right. Get the deduplication right. Respond fast. The rest follows.