Back to Payments

Payments · Lesson 3

Webhooks: Verify, Idempotent, Replayable

The provider POSTs to your endpoint when something changes. Your endpoint has to handle three realities: spoofed requests, duplicate events, and out-of-order delivery. Three properties solve all three.

1. Verify the signature

Anyone who knows your webhook URL can POST to it. Without verification, an attacker who knows the URL can fake "payment succeeded" events and walk off with your goods. Every reputable provider signs webhook payloads. Your code has to verify the signature before trusting anything in the body.

# Stripe-style example:
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def stripe
    payload   = request.body.read
    signature = request.env["HTTP_STRIPE_SIGNATURE"]
    secret    = ENV.fetch("STRIPE_WEBHOOK_SECRET")

    event = Stripe::Webhook.construct_event(payload, signature, secret)
    # Raises Stripe::SignatureVerificationError on bad signatures.

    ProcessStripeEvent.call(event)
    head :ok
  rescue Stripe::SignatureVerificationError
    head :bad_request
  end
end

Note the skip_before_action :verify_authenticity_token. Webhooks are cross-origin POSTs from the provider, so Rails' CSRF check would reject them. The signature is what proves authenticity here, not the CSRF token.

2. Make event processing idempotent

Providers send webhooks at-least-once. If your endpoint times out or returns a 5xx, the provider retries — sometimes minutes later, sometimes hours. The same event arrives more than once. Processing it twice can move a payment forward twice, send two emails, or credit two ledger entries.

The fix: every event has a unique ID. Store it. If you've seen it, skip.

class ProcessStripeEvent
  def self.call(event)
    return if WebhookEvent.exists?(provider: "stripe", event_id: event.id)

    ActiveRecord::Base.transaction do
      WebhookEvent.create!(provider: "stripe", event_id: event.id, kind: event.type)
      dispatch(event)
    end
  end

  def self.dispatch(event)
    case event.type
    when "payment_intent.succeeded" then PaymentSucceeded.call(event.data.object)
    when "payment_intent.payment_failed" then PaymentFailed.call(event.data.object)
    # ...
    end
  end
end

The webhook_events table has a unique index on (provider, event_id). Two duplicate events racing each other will both try to insert; the second one hits the unique violation and gets rolled back. The first one wins, processes once.

3. Be safe under reordering

Webhooks don't always arrive in the order the events happened. You might receive payment_intent.succeeded after payment_intent.refunded. If your handler naively applies "succeeded → mark order paid," you've now marked a refunded order as paid.

Two defenses:

  • Check the current state of the payment in your database before applying the transition. If the payment is already refunded, ignore the late "succeeded" event.
  • For non-trivial state changes, re-fetch the truth from the provider. The webhook is a notification; the provider's API is the truth. Stripe::PaymentIntent.retrieve(id) tells you the current state, regardless of which webhook arrived first.

Return fast, do the work in a job

Webhook providers have a timeout (Stripe's is ~30s, but 2-3s is the polite target). If your handler does database work, sends emails, hits other APIs, you run out the clock and get a retry. Return 200 OK as soon as you've persisted the event. Enqueue a job to do the rest.

def stripe
  event = verify_event!
  WebhookEvent.find_or_create_by!(provider: "stripe", event_id: event.id) do |we|
    we.payload = event.to_hash
    we.kind = event.type
  end
  ProcessStripeEventJob.perform_later(event.id)
  head :ok
end