Skip to content

Webhooks: How External Systems Talk Back to Yours — Securely

Published: at 10:00 AM (7 min read)

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

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:

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.