Rails Webhook Processing: Handtekeningverificatie, Idempotentie en Achtergrondverwerking
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:
- Authenticatie: Komt dit daadwerkelijk van Stripe, of fabriceert iemand nep-events?
- Idempotentie: Wat gebeurt er als hetzelfde event twee keer aankomt — of tien keer?
- 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.
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 TouchRelated Articles
Ruby on Rails Feature Flags: Complete Guide met Flipper, Rollout en Custom Redis Implementatie
April 13, 2026
Rails Concerns: Wanneer Ze Code Opschonen en Wanneer Ze Complexiteit Verbergen
March 13, 2026
Rails 8 Meerdere Databases: Read Replicas, Sharding en Automatische Connectie-switching
March 12, 2026
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