Omaship

March 20, 2026 . 14 min read

Webhooks in Rails 8 SaaS: How to Receive, Verify, and Process Stripe, GitHub, and Third-Party Webhooks in 2026

Jeronim Morina

Jeronim Morina

Founder, Omaship

Your SaaS does not operate in isolation. Stripe tells you when a payment succeeds. GitHub tells you when a push lands. Postmark tells you when an email bounces. These services talk to your app through webhooks -- HTTP callbacks that arrive uninvited, at any time, and must be handled correctly or your billing breaks, your provisioning stalls, and your users see stale data.

But production webhook handling is full of sharp edges: replay attacks, duplicate deliveries, slow processing that times out the sender, unverified payloads from attackers. Get any of these wrong and you lose revenue or open security holes. This guide gives you the complete webhook stack for Rails 8: secure endpoints, signature verification, idempotent processing, background jobs, and testing.

Why webhooks matter for SaaS

Every SaaS integrates with external services. The question is not whether you will handle webhooks but how many. Here are the most common webhook sources for a typical Rails SaaS:

  • Stripe -- payment succeeded, subscription cancelled, invoice payment failed. If you miss a customer.subscription.deleted event, a cancelled customer keeps accessing paid features.
  • GitHub -- push events, pull request opened, repository created. If your SaaS provisions repositories, GitHub webhooks are your primary data source.
  • Email providers -- Postmark, Resend, and SES send bounce notifications and spam complaints. Ignoring these tanks your sender reputation.
  • Payment processors -- Paddle, Lemon Squeezy, and other Merchant of Record platforms rely on webhooks as the primary integration mechanism.

The common thread: webhooks are how the outside world tells your app that something happened. Polling is wasteful. Webhooks give you real-time updates with zero wasted requests.

Building a webhook endpoint in Rails 8

A webhook endpoint receives POST requests from external servers -- no user session, no CSRF token, no browser. Here is the minimal implementation:

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

  def create
    payload = request.body.read
    signature = request.headers["Stripe-Signature"]

    event = verify_stripe_signature(payload, signature)
    return head :bad_request unless event

    ProcessStripeWebhookJob.perform_later(event.type, event.data.object.to_json)
    head :ok
  end

  private

  def verify_stripe_signature(payload, signature)
    Stripe::Webhook.construct_event(
      payload,
      signature,
      Rails.application.credentials.dig(:stripe, :webhook_secret)
    )
  rescue Stripe::SignatureVerificationError
    nil
  end
end

The route maps to a dedicated namespace:

# config/routes.rb
namespace :webhooks do
  resource :stripe, only: :create, controller: "stripe"
  resource :github, only: :create, controller: "github"
  resource :postmark, only: :create, controller: "postmark"
end

Three critical details in this code:

  • skip_before_action :verify_authenticity_token -- webhooks come from external servers, not browsers. They will never have a CSRF token. Without this line, Rails rejects every webhook with a 422.
  • request.body.read -- you must read the raw request body before Rails parses it. Signature verification requires the exact bytes that were signed. If you use params instead, Rails may re-serialize the JSON differently, and the signature check fails.
  • head :ok immediately -- acknowledge the webhook before doing any real work. Stripe, GitHub, and most providers expect a 2xx response within 5-30 seconds. If you process the event synchronously and it takes too long, the provider marks the delivery as failed and retries -- creating duplicate events.

Signature verification: Stripe, GitHub, and generic HMAC-SHA256

Never trust a webhook payload without verifying its signature. Without verification, anyone who discovers your endpoint URL can send a fake customer.subscription.created event to give themselves a free subscription.

Stripe signature verification

Stripe signs every webhook with a timestamp and HMAC-SHA256 hash. The stripe gem handles verification:

def verify_stripe_signature(payload, signature)
  Stripe::Webhook.construct_event(
    payload,
    signature,
    Rails.application.credentials.dig(:stripe, :webhook_secret)
  )
rescue Stripe::SignatureVerificationError => e
  Rails.logger.warn "Stripe webhook signature verification failed: #{e.message}"
  nil
end

The webhook secret comes from your Stripe dashboard under Developers > Webhooks. Store it in Rails encrypted credentials, never in environment variables that might leak into logs.

GitHub signature verification

GitHub uses HMAC-SHA256 with a shared secret. The signature arrives in the X-Hub-Signature-256 header:

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

  def create
    event_type = request.headers["X-GitHub-Event"]
    delivery_id = request.headers["X-GitHub-Delivery"]

    ProcessGithubWebhookJob.perform_later(
      event_type: event_type,
      delivery_id: delivery_id,
      payload: @payload
    )
    head :ok
  end

  private

  def verify_github_signature
    @payload = request.body.read
    signature = request.headers["X-Hub-Signature-256"]

    unless valid_github_signature?(@payload, signature)
      head :unauthorized
    end
  end

  def valid_github_signature?(payload, signature)
    return false unless signature.present?

    secret = Rails.application.credentials.dig(:github, :webhook_secret)
    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, payload)

    ActiveSupport::SecurityUtils.secure_compare(expected, signature)
  end
end

Two important details: use ActiveSupport::SecurityUtils.secure_compare instead of == for timing-safe comparison -- a regular comparison leaks information an attacker can exploit. Also note the X-GitHub-Delivery header -- you will use it for idempotency.

Generic HMAC-SHA256 verification

Most webhook providers use HMAC-SHA256. Here is a reusable concern:

# app/controllers/concerns/webhook_verifiable.rb
module WebhookVerifiable
  extend ActiveSupport::Concern

  private

  def verify_hmac_signature(payload, signature, secret, algorithm: "SHA256", prefix: "sha256=")
    return false unless signature.present? && secret.present?

    expected = prefix + OpenSSL::HMAC.hexdigest(algorithm, secret, payload)
    ActiveSupport::SecurityUtils.secure_compare(expected, signature)
  end
end

This covers Paddle, Lemon Squeezy, Svix-based providers, and any service that signs payloads with HMAC. Each differs only in the header name, prefix format, and credential.

Idempotency: processing events exactly once

Webhook providers retry failed deliveries. Stripe retries up to 3 times over 72 hours. Network glitches can cause your server to receive the same event multiple times even when it responded successfully. If your handler creates a record on every delivery, you get duplicates. Your processing must be idempotent: running the same event twice produces the same result as running it once.

# app/models/webhook_event.rb
class WebhookEvent < ApplicationRecord
  # Columns: provider (string), event_id (string), event_type (string),
  #          payload (json), processed_at (datetime), created_at (datetime)

  validates :provider, :event_id, presence: true
  validates :event_id, uniqueness: { scope: :provider }

  scope :unprocessed, -> { where(processed_at: nil) }

  def processed?
    processed_at.present?
  end

  def mark_processed!
    update!(processed_at: Time.current)
  end
end
# db/migrate/XXXXXX_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.json :payload
      t.datetime :processed_at

      t.timestamps
    end

    add_index :webhook_events, [ :provider, :event_id ], unique: true
    add_index :webhook_events, :processed_at
  end
end

The pattern: log every incoming webhook event with its provider-assigned ID before processing. If the same event arrives again, the unique index prevents a duplicate record. The job checks whether the event was already processed before doing any work:

# app/jobs/process_stripe_webhook_job.rb
class ProcessStripeWebhookJob < ApplicationJob
  queue_as :webhooks

  def perform(event_type, payload_json)
    payload = JSON.parse(payload_json)
    event_id = payload["id"]

    webhook_event = WebhookEvent.find_or_create_by!(
      provider: "stripe",
      event_id: event_id
    ) do |we|
      we.event_type = event_type
      we.payload = payload
    end

    return if webhook_event.processed?

    case event_type
    when "customer.subscription.created"
      handle_subscription_created(payload)
    when "customer.subscription.deleted"
      handle_subscription_deleted(payload)
    when "invoice.payment_failed"
      handle_payment_failed(payload)
    end

    webhook_event.mark_processed!
  end

  private

  def handle_subscription_created(payload)
    # Find user by Stripe customer ID, activate subscription
  end

  def handle_subscription_deleted(payload)
    # Find user by Stripe customer ID, downgrade to free plan
  end

  def handle_payment_failed(payload)
    # Notify user, retry billing, or flag account
  end
end

This gives you three guarantees: every event is logged for auditing, no event is processed twice, and failed processing can be retried by resetting processed_at to nil.

Background processing with Solid Queue

The golden rule: acknowledge fast, process later. Your endpoint should verify the signature, enqueue a job, and return 200. All business logic happens in the background. Rails 8 with Solid Queue makes this straightforward -- configure a dedicated queue so webhooks do not compete with other work:

# config/solid_queue.yml
production:
  dispatchers:
    - polling_interval: 1
      batch_size: 500
  workers:
    - queues: "webhooks"
      threads: 3
      processes: 1
      polling_interval: 0.5
    - queues: "default,mailers"
      threads: 5
      processes: 1
      polling_interval: 1

If your default queue is backed up with email jobs, a Stripe cancellation event should not wait in line. Configure retry behavior on the job:

# app/jobs/process_stripe_webhook_job.rb
class ProcessStripeWebhookJob < ApplicationJob
  queue_as :webhooks
  retry_on StandardError, wait: :polynomially_longer, attempts: 5

  discard_on ActiveRecord::RecordNotFound

  # ...
end

Polynomial backoff retries at increasing intervals (3s, 18s, 83s, 258s, 625s). After five attempts, the event stays in the dead set for manual review. discard_on prevents infinite retries when a referenced record was deleted between receipt and processing.

Error handling, retries, and dead letter queues

Webhook processing fails. APIs time out, databases go down, your code has bugs. The question is not whether failures happen but how you recover from them.

Layer 1: job-level retries

Solid Queue handles transient failures automatically with retry_on. Database connection errors, brief network outages, and temporary API rate limits resolve themselves after a retry.

Layer 2: provider-level retries

If your endpoint returns a 5xx status, the provider retries the entire delivery. Stripe retries up to 3 times over 72 hours. This is your safety net for total outages -- if your server is down for an hour, Stripe will redeliver the events when it comes back up.

Layer 3: manual replay from the event log

Because every webhook is logged in the webhook_events table, you can replay failed events manually or with a rake task:

# lib/tasks/webhooks.rake
namespace :webhooks do
  desc "Replay unprocessed webhook events for a provider"
  task :replay, [ :provider ] => :environment do |_t, args|
    events = WebhookEvent.where(provider: args[:provider]).unprocessed

    events.find_each do |event|
      job_class = "Process#{event.provider.classify}WebhookJob".constantize
      job_class.perform_later(event.event_type, event.payload.to_json)
    end

    puts "Enqueued #{events.count} events for replay"
  end
end

Run rails webhooks:replay[stripe] to re-process all unprocessed Stripe events. This is your escape hatch when a bug in your handler caused events to fail silently and you need to reprocess them after fixing the code.

Track two metrics: processing latency (time between receipt and completion) and failure rate. A spike in failures usually means a code bug or API outage. Query WebhookEvent.unprocessed.where("created_at < ?", 1.hour.ago).count in a recurring job and alert when the backlog grows.

Testing webhooks locally

Webhooks come from external servers, but your development machine is behind NAT. The external service cannot reach localhost:3000. You have three options:

Stripe CLI (for Stripe webhooks)

The Stripe CLI is the best option for Stripe-specific testing. It intercepts events from your Stripe test account and forwards them to your local server:

# Install and login
brew install stripe/stripe-cli/stripe
stripe login

# Forward events to your local endpoint
stripe listen --forward-to localhost:3000/webhooks/stripe

# In another terminal, trigger a test event
stripe trigger customer.subscription.created

The CLI outputs the webhook signing secret for local testing. Use it in your development credentials so signature verification works locally.

Tunnel alternatives (for any webhook provider)

For non-Stripe webhooks, you need a tunnel that exposes your local server to the internet:

  • ngrok -- the established choice. Free tier gives you a random subdomain. ngrok http 3000 and paste the URL into your webhook provider's settings.
  • Cloudflare Tunnel -- free, no account required for quick tunnels. cloudflared tunnel --url http://localhost:3000. More reliable than ngrok for long-running sessions.
  • localhost.run -- SSH-based, no installation required. ssh -R 80:localhost:3000 [email protected]. Good for one-off testing.

Integration tests (no tunnel required)

For automated tests, skip the tunnel entirely. Construct webhook payloads and POST them directly to your endpoint. Here is a Stripe webhook test:

# test/controllers/webhooks/stripe_controller_test.rb
class Webhooks::StripeControllerTest < ActionDispatch::IntegrationTest
  test "processes subscription created event" do
    payload = {
      id: "evt_test_123",
      type: "customer.subscription.created",
      data: { object: { id: "sub_123", customer: "cus_123", status: "active" } }
    }.to_json

    post webhooks_stripe_path,
      params: payload,
      headers: {
        "Content-Type" => "application/json",
        "Stripe-Signature" => generate_stripe_signature(payload)
      }

    assert_response :ok
    assert_enqueued_with(job: ProcessStripeWebhookJob)
  end

  test "rejects invalid signature" do
    post webhooks_stripe_path,
      params: { id: "evt_fake" }.to_json,
      headers: { "Content-Type" => "application/json", "Stripe-Signature" => "invalid" }

    assert_response :bad_request
  end

  private

  def generate_stripe_signature(payload)
    timestamp = Time.now.to_i
    secret = Rails.application.credentials.dig(:stripe, :webhook_secret)
    sig = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
    "t=#{timestamp},v1=#{sig}"
  end
end

The test generates a valid signature using the same secret your controller uses. This tests CSRF skip, raw body reading, signature verification, and job enqueuing -- without any network dependency.

Webhook security checklist

Webhook endpoints are public-facing attack surfaces. They accept unauthenticated POST requests from the internet. Treat them with the same care you would treat a public API:

  • Always verify signatures. Every webhook payload must pass signature verification before any processing. No exceptions. An unverified webhook endpoint is an open door for attackers to inject fake events.
  • Use timing-safe comparison. ActiveSupport::SecurityUtils.secure_compare prevents timing attacks on signature verification. Never use == to compare signatures.
  • Read the raw body, not params. Use request.body.read for the payload bytes. Rails parameter parsing can alter the payload (reordering keys, changing encoding), which invalidates the signature.
  • Reject stale timestamps. Stripe signatures include a timestamp. Reject events where the timestamp is more than 5 minutes old to prevent replay attacks. The Stripe gem does this by default with a 300-second tolerance.
  • Store secrets in encrypted credentials. Webhook signing secrets belong in Rails.application.credentials, not in environment variables or config files that might be committed to version control.
  • Rate limit your webhook endpoints. Add Rack::Attack throttling to prevent abuse. A legitimate provider sends a finite number of events per minute. An attacker will flood the endpoint.
  • Log but never expose errors. Return head :bad_request or head :unauthorized with no body. Never return error details that reveal your internal structure or the specific reason verification failed.
  • Use HTTPS exclusively. Webhook payloads often contain sensitive data (customer IDs, email addresses, payment details). TLS encryption in transit is non-negotiable in production.

How to apply this in Omaship today

Omaship includes one production webhook endpoint today: the Paddle billing webhook controller. It demonstrates the core production patterns from this guide -- endpoint hardening, signature verification, and delegating processing work away from the request cycle.

For Stripe, GitHub, and custom provider webhooks, use the examples in this article as your implementation blueprint. Keep the same structure: verify first, store or dedupe by provider event ID, then hand off side effects to background jobs.

A broader first-class webhook system is still on the roadmap. Until that lands, this guide is the recommended way to implement webhook flows safely and consistently in Omaship projects.

Ship your SaaS with webhooks that work from day one.

Omaship gives you the billing webhook baseline and the Rails patterns to implement secure webhook flows for Stripe, GitHub, and custom providers.

Start building

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy