35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Webhook Processing: Handtekeningverificatie, Idempotentie en Achtergrondverwerking

Rails Webhook Processing: Handtekeningverificatie, Idempotentie en Achtergrondverwerking

Roger Heykoop
Ruby on Rails, DevOps
Rails webhook processing goed gedaan: HMAC-handtekeningverificatie, idempotente handlers, Stripe- en GitHub-voorbeelden, achtergrondtaken en foutherstel.

Een Stripe-webhook raakte ons endpoint. Betaling bevestigd, abonnement geactiveerd, welkomstmail verstuurd. Behalve dat hetzelfde event drie keer achter elkaar binnenkwam — Stripe herprobeert bij alles behalve een 2xx-response, en onze database was die middag traag. Drie welkomstmails. Eén erg verwarring brengende klant.

Dat was ongeveer twaalf jaar geleden. Sindsdien heb ik honderden miljoenen webhook-events verwerkt, en de les is altijd dezelfde: webhook processing ziet er eenvoudig uit en is het niet. De provider schiet en vergeet. Jouw taak is om de bron te verifiëren, snel te reageren en de verwerking idempotent te maken. De meeste Rails-webhook-tutorials behandelen niets van dit alles.

Waarom Rails Webhook Processing Lastig Is

Webhooks komen aan via HTTP. Je endpoint heeft ruwweg vijf seconden om te reageren met een 2xx, anders markeert de provider de levering als mislukt en probeert het opnieuw. Als je response zes seconden duurt omdat je synchroon databasewerk doet, e-mails verstuurt en externe API’s aanroept — dan word je opnieuw geprobeerd. Nu heb je hetzelfde event twee keer verwerkt.

Drie problemen om op te lossen:

  1. Authenticatie: Komt dit daadwerkelijk van Stripe, of fabriceert iemand nep-events?
  2. Idempotentie: Wat gebeurt er als hetzelfde event twee keer aankomt — of tien keer?
  3. Latentie: Hoe reageer je binnen vijf seconden als het echte werk dertig seconden kost?

Rails maakt alle drie oplosbaar. Dit is hoe.

Handtekeningverificatie: Alles Zonder Handtekening Weigeren

Elke serieuze webhook-provider ondertekent zijn payloads. Stripe gebruikt HMAC-SHA256. GitHub gebruikt HMAC-SHA256. Shopify ook. Het patroon is consistent: de provider stuurt een handtekening mee in een header, jij herberekent die met je gedeeld geheim en verwerpt alles dat niet overeenkomt. Als je deze stap overslaat, kan iedereen naar je endpoint POST-en met een verzonnen event en echte bijwerkingen veroorzaken.

Stripe Webhook Verificatie in Rails

Stripe stuurt een Stripe-Signature-header mee met een tijdstempel en de HMAC-digest. Het tijdstempel voorkomt replay-aanvallen — Stripe’s bibliotheek verwerpt handtekeningen ouder dan vijf minuten.

# app/controllers/webhooks/stripe_controller.rb
class Webhooks::StripeController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_stripe_signature

  def create
    StripeWebhookJob.perform_later(
      @event.id,
      @event.type,
      @event.data.object.to_h
    )
    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

    @event = Stripe::Webhook.construct_event(payload, sig_header, secret)
  rescue Stripe::SignatureVerificationError => e
    Rails.logger.warn("Stripe handtekeningverificatie mislukt: #{e.message}")
    head :bad_request
  end
end

Twee cruciale details: skip_before_action :verify_authenticity_token is verplicht — Stripe is geen browser en heeft geen CSRF-token. En je moet request.body.read aanroepen vóórdat iets anders de body aanraakt, want Stripe’s bibliotheek valideert de ruwe bytes, niet de geparseerde JSON.

GitHub Webhook Verificatie

GitHub gebruikt een eenvoudiger schema — één header, geen tijdstempelcomponent:

# app/controllers/webhooks/github_controller.rb
class Webhooks::GithubController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_github_signature

  def create
    event_name = request.headers["X-GitHub-Event"]
    GithubWebhookJob.perform_later(event_name, request.raw_post)
    head :ok
  end

  private

  def verify_github_signature
    secret   = Rails.application.credentials.github_webhook_secret
    body     = request.body.read
    expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
    received = request.headers["X-Hub-Signature-256"].to_s

    unless ActiveSupport::SecurityUtils.secure_compare(received, expected)
      Rails.logger.warn("GitHub handtekening komt niet overeen voor #{request.remote_ip}")
      head :unauthorized and return
    end

    request.body.rewind
  end
end

ActiveSupport::SecurityUtils.secure_compare is hier belangrijk. Een gewone ==-vergelijking stopt bij de eerste niet-overeenkomende byte, wat het kwetsbaar maakt voor timing-aanvallen. Constante-tijd-vergelijking elimineert dat.

Een Herbruikbare HMAC-Verifier

Als je meerdere providers integreert die hetzelfde algoritme gebruiken, extraheer het patroon één keer:

# app/services/webhook_signature_verifier.rb
class WebhookSignatureVerifier
  def self.valid?(body:, secret:, received:, algorithm: "SHA256", prefix: "sha256=")
    expected = "#{prefix}#{OpenSSL::HMAC.hexdigest(algorithm, secret, body)}"
    ActiveSupport::SecurityUtils.secure_compare(received.to_s, expected)
  end
end

Gebruik het overal:

unless WebhookSignatureVerifier.valid?(
  body:     request.body.read,
  secret:   Rails.application.credentials.shopify_webhook_secret,
  received: request.headers["X-Shopify-Hmac-SHA256"],
  prefix:   ""  # Shopify gebruikt geen prefix
)
  head :unauthorized and return
end

Idempotentie: Eén Keer Verwerken, Hoe Vaak Het Ook Aankomt

Stripe herprobeert bij 5xx-responses en time-outs. GitHub herprobeert bij alles wat geen 2xx is. Je verwerkingslogica moet veilig zijn om meerdere keren uit te voeren voor hetzelfde event.

De schoonste aanpak: sla elke ontvangen event-ID op en sla verwerking over als je het al hebt afgehandeld.

Database-gebaseerde Idempotentie

# db/migrate/20260414120000_create_webhook_events.rb
class CreateWebhookEvents < ActiveRecord::Migration[8.0]
  def change
    create_table :webhook_events do |t|
      t.string  :provider,      null: false
      t.string  :event_id,      null: false
      t.string  :event_type,    null: false
      t.jsonb   :payload,       null: false, default: {}
      t.string  :status,        null: false, default: "pending"
      t.text    :error_message
      t.timestamps
    end

    add_index :webhook_events, [:provider, :event_id], unique: true
  end
end
# app/models/webhook_event.rb
class WebhookEvent < ApplicationRecord
  enum :status, { pending: "pending", processed: "processed", failed: "failed" }

  def self.process_once(provider:, event_id:, event_type:, payload:)
    record = create!(
      provider:   provider,
      event_id:   event_id,
      event_type: event_type,
      payload:    payload
    )
    yield(record)
    record.update!(status: :processed)
  rescue ActiveRecord::RecordNotUnique
    Rails.logger.info("Dubbel #{provider}-event #{event_id} — overgeslagen")
  end
end

De rescue ActiveRecord::RecordNotUnique op de database-level unieke index is bewust. Controleren-en-dan-invoegen heeft een TOCTOU-race condition: twee gelijktijdige leveringen van hetzelfde event kunnen allebei de exists?-check doorstaan en dan allebei de insert proberen. Het opvangen van de constraint-schending op databaseniveau is waterdicht.

Gebruik in de Job

# app/jobs/stripe_webhook_job.rb
class StripeWebhookJob < ApplicationJob
  queue_as :webhooks

  retry_on StandardError, wait: :polynomially_longer, attempts: 5
  discard_on ActiveRecord::RecordNotUnique

  def perform(event_id, event_type, payload)
    WebhookEvent.process_once(
      provider:   "stripe",
      event_id:   event_id,
      event_type: event_type,
      payload:    payload
    ) do
      StripeEventHandler.handle(event_type, payload)
    end
  end
end

discard_on ActiveRecord::RecordNotUnique stopt retries op duplicaten voordat ze process_once bereiken. retry_on StandardError met polynomiale backoff behandelt tijdelijke fouten — database niet beschikbaar, externe API-time-out, wat dan ook.

Events Routeren: Het Handler-Registry-Patroon

Houd de controller en job dun. Delegeer naar gerichte handler-klassen.

# app/services/stripe_event_handler.rb
class StripeEventHandler
  HANDLERS = {
    "checkout.session.completed"    => CheckoutCompletedHandler,
    "customer.subscription.deleted" => SubscriptionCancelledHandler,
    "invoice.payment_failed"        => PaymentFailedHandler
  }.freeze

  def self.handle(event_type, payload)
    handler_class = HANDLERS[event_type]

    if handler_class
      handler_class.new(payload).call
    else
      Rails.logger.debug("Geen handler geregistreerd voor Stripe-event: #{event_type}")
    end
  end
end

Elke handler is een kleine klasse die één ding doet:

# app/services/checkout_completed_handler.rb
class CheckoutCompletedHandler
  def initialize(payload)
    @payload = payload
  end

  def call
    customer_id = @payload.fetch("customer")
    user = User.find_by!(stripe_customer_id: customer_id)

    user.activate_subscription!
    UserMailer.welcome(user).deliver_later
  end
end

Deze structuur maakt testen eenvoudig. Je hoeft geen nep-HTTP-verzoeken te sturen om checkout-activering te testen — instantieer de handler direct in een unit-test met een fixture-payload. Geen controllers, geen jobs, geen database-level idempotentie om omheen te werken.

Webhook-controllers Testen

# spec/requests/webhooks/stripe_spec.rb
RSpec.describe "Webhooks::Stripe", type: :request do
  let(:secret)   { "whsec_test_secret" }
  let(:event_id) { "evt_test_#{SecureRandom.hex(8)}" }
  let(:payload)  do
    {
      id:   event_id,
      type: "checkout.session.completed",
      data: { object: { customer: "cus_abc123" } }
    }.to_json
  end
  let(:timestamp) { Time.now.to_i }

  before do
    allow(Rails.application.credentials).to receive(:stripe_webhook_secret).and_return(secret)
  end

  def stripe_sig(body, secret, ts)
    signed = "#{ts}.#{body}"
    "t=#{ts},v1=#{OpenSSL::HMAC.hexdigest('SHA256', secret, signed)}"
  end

  it "zet de job in de wachtrij voor een geldig event" do
    expect(StripeWebhookJob).to receive(:perform_later).once

    post "/webhooks/stripe",
         params:  payload,
         headers: { "Stripe-Signature" => stripe_sig(payload, secret, timestamp),
                    "Content-Type" => "application/json" }

    expect(response).to have_http_status(:ok)
  end

  it "geeft 400 terug bij een ongeldige handtekening" do
    post "/webhooks/stripe",
         params:  payload,
         headers: { "Stripe-Signature" => "t=#{timestamp},v1=ongedighandtekening",
                    "Content-Type" => "application/json" }

    expect(response).to have_http_status(:bad_request)
  end
end

Bereken de verwachte HMAC-handtekening zelf in de test — gebruik hetzelfde algoritme als de provider. Controller-gedrag is volledig testbaar zonder live sandbox.

Routes en Wachtrijconfiguratie

# config/routes.rb
Rails.application.routes.draw do
  namespace :webhooks do
    post "/stripe", to: "stripe#create"
    post "/github", to: "github#create"
  end
end

Zet webhook-endpoints niet in je API-namespace of achter authenticatie-middleware. Ze hebben toegang tot de ruwe body nodig en geen CSRF-bescherming. Namespacing onder webhooks/ houdt ze zichtbaar en geïsoleerd.

Reserveer een aparte wachtrij voor webhook-jobs:

# config/solid_queue.yml
dispatchers:
  - polling_interval: 0.1
    batch_size: 500

workers:
  - queues: webhooks
    threads: 5
  - queues: default,mailers
    threads: 3

Afzonderlijke workers zorgen ervoor dat een burst aan webhook-verkeer je gewone jobs niet vertraagt, en vice versa. Drie tot vijf webhook-workers is meestal voldoende.

Mislukte Events Monitoren

De status-kolom in webhook_events is je auditspoor. Controleer periodiek op mislukkingen:

# In een Rake-taak, adminsconsole of geplande job
WebhookEvent.failed.order(created_at: :desc).limit(50).each do |event|
  Rails.logger.error(
    "[webhook] #{event.provider}/#{event.event_type} (#{event.event_id}): #{event.error_message}"
  )
end

De payload-kolom bewaart de originele event-data, zodat handmatige herverwerking altijd mogelijk is:

event = WebhookEvent.find_by(event_id: "evt_1234")
StripeEventHandler.handle(event.event_type, event.payload)
event.update!(status: :processed, error_message: nil)

Webhook-verwerking is iets wat elke Rails-applicatie uiteindelijk nodig heeft en bijna iedereen de eerste keer verkeerd doet. De drievoudige welkomstmail is een rite of passage. Met HMAC-verificatie, een database-gebaseerde idempotentielaag en een eigen achtergrondwachtrij stuur je ze nooit meer drie keer.

Voor meer over achtergrondtaakpatronen, zie de Rails achtergrondtaken-gids met Solid Queue en Sidekiq. Voor gelijktijdig schrijven en vergrendelingspatronen behandelt zero-downtime databasemigraties dezelfde klasse problemen.

Bouw je integraties met Stripe, GitHub of een dozijn andere providers? TTB Software brengt al negentien jaar Rails naar productie. We hebben meer webhooks verwerkt dan we kunnen tellen — en ook meer dubbele-e-mailbugs opgelost.

Veelgestelde Vragen

Hoe verifieer ik Stripe-webhookhandtekeningen in Rails?

Gebruik Stripe::Webhook.construct_event met de ruwe requestbody, de waarde van de Stripe-Signature-header en je webhook-signeringsgeheim uit het Stripe-dashboard. Lees altijd request.body.read voordat Rails de body parseert. Zet skip_before_action :verify_authenticity_token op de controller. Vang Stripe::SignatureVerificationError op en retourneer HTTP 400.

Wat betekent idempotente webhook-verwerking in Rails in de praktijk?

Het betekent dat hetzelfde event-ID twee keer verwerken hetzelfde resultaat geeft als één keer — geen dubbele e-mails, geen dubbele kosten, geen extra records. Implementeer het door een webhook_events-tabel te maken met een unieke index op (provider, event_id), een record in te voegen vóór verwerking en ActiveRecord::RecordNotUnique op te vangen om duplicaten stilzwijgend over te slaan.

Moet Rails webhook-endpoints synchroon of via achtergrondtaken verwerken?

Altijd via achtergrondtaken. De meeste providers proberen het opnieuw als je niet binnen vijf tot dertig seconden reageert. Synchrone verwerking riskeert time-outs onder belasting, en elke tijdelijke fout activeert een herpoging. Reageer direct met HTTP 200 na het in de wachtrij plaatsen, en laat de job de echte logica afhandelen.

Hoe test ik Rails webhook-controllers zonder de live provider-API te gebruiken?

Bereken de HMAC-handtekening zelf in de test — gebruik hetzelfde algoritme als de provider (doorgaans SHA256 met een gedeeld geheim) en stel de header handmatig in. Unit- en requesttests kunnen volledig op zichzelf staan. Bewaar live sandboxtests voor end-to-end acceptatiescenario’s.

#rails #webhooks #stripe #idempotency #background-jobs #security #hmac
R

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation