Rails Webhook Receiver Pattern with Idempotency
Webhooks are the backbone of many modern application integrations, allowing services to communicate asynchronously and react to events in real-time. Whether you're integrating with a payment processor like Stripe, a version control system like GitHub, or a CRM, you'll likely encounter webhooks. However, building a robust webhook receiver in Rails isn't just about spinning up an endpoint; it's about handling the inherent unreliability of network communication, primarily through idempotency.
This article will guide you through building a resilient Rails webhook receiver, focusing on patterns that ensure your application processes events correctly, even when faced with duplicate deliveries or network glitches.
The Webhook Challenge: Unreliability and Duplicates
At its core, a webhook is an HTTP POST request sent by one service to another when a specific event occurs. Sounds simple, right? The complexity arises from the real world:
- Network Latency and Timeouts: The sending service might not receive an immediate
200 OKresponse due to network issues, leading it to retry sending the same event. - Retries: Most webhook providers implement retry mechanisms. If your endpoint is temporarily down or slow, they'll try again, often sending the exact same payload.
- Distributed Systems: In a highly distributed environment, it's virtually impossible to guarantee "exactly once" delivery. "At least once" delivery is the more realistic promise.
- Race Conditions: Multiple events might arrive simultaneously, or in an unexpected order, especially if they relate to the same resource.
The consequence? Your Rails application might receive the same webhook event multiple times. Without proper handling, this could lead to duplicate database entries, incorrect state transitions, or even double-charging customers. This is where idempotency becomes crucial.
Basic Rails Webhook Receiver Structure
Let's start with a basic Rails setup for receiving webhooks. We'll use a dedicated controller and process the event asynchronously using a background job.
First, define a route for your webhook endpoint. It's good practice to use a unique, hard-to-guess path.
# config/routes.rb
Rails.application.routes.draw do
post '/webhooks/stripe', to: 'webhooks#stripe'
post '/webhooks/github/:secret', to: 'webhooks#github' # Example with a secret in URL
end
Next, create a WebhooksController. For security, you should always verify the webhook's signature to ensure it genuinely comes from the expected sender and hasn't been tampered with.
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token # Webhooks don't send CSRF tokens
before_action :verify_stripe_signature, only: [:stripe]
before_action :verify_github_signature, only: [:github]
def stripe
# Stripe events are typically wrapped in a Stripe::Event object
event = JSON.parse(request.body.read)
# Or, if using the Stripe Ruby gem:
# event = Stripe::Webhook.construct_event(
# request.body.read,
# request.env['HTTP_STRIPE_SIGNATURE'],
# Rails.application.credentials.stripe[:webhook_secret]
# )
# Enqueue a background job for processing
StripeWebhookJob.perform_later(event.to_h) # Pass as hash to avoid object serialization issues
head :ok
rescue JSON::ParserError => e
render json: { error: 'Invalid JSON payload' }, status: :bad_request
rescue Stripe::SignatureVerificationError => e
render json: { error: 'Invalid Stripe signature' }, status: :unauthorized
rescue StandardError => e
# Log the error and return a 500
Rails.logger.error "Error processing Stripe webhook: #{e.message}"
head :internal_server_error
end
def github
# GitHub events include an 'X-GitHub-Delivery' header, which is a UUID
# and an 'X-GitHub-Event' header, e.g., 'push', 'issues', etc.
event_id = request.env['HTTP_X_GITHUB_DELIVERY']
event_type = request.env['HTTP_X_GITHUB_EVENT']
payload = JSON.parse(request.body.read)
# Enqueue a background job for processing
GithubWebhookJob.perform_later(event_id, event_type, payload)
head :ok
rescue JSON::ParserError => e
render json: { error: 'Invalid JSON payload' }, status: :bad_request
rescue SignatureVerificationError => e # Custom error class for GitHub signature
render json: { error: 'Invalid GitHub signature' }, status: :unauthorized
rescue StandardError => e
Rails.logger.error "Error processing GitHub webhook: #{e.message}"
head :internal_server_error
end
private
def verify_stripe_signature
# Implement Stripe signature verification using the Stripe gem
# For example:
# Stripe::Webhook.construct_event(
# request.body.read,
# request.env['HTTP_STRIPE_SIGNATURE'],
# Rails.application.credentials.stripe[:webhook_secret]
# )
# This method would raise Stripe::SignatureVerificationError on failure.
# For simplicity, we'll assume it's handled by the `rescue` block above.
end
def verify_github_signature
# Implement GitHub signature verification.
# Requires the raw request body and the secret you configured.
# header_signature = request.env['HTTP_X_HUB_SIGNATURE_256'] || request.env['HTTP_X_HUB_SIGNATURE']
# expected_signature = 'sha256=' + OpenSSL::HMAC.hexdigest(
# OpenSSL::Digest.new('sha256'),
# params[:secret], # The secret from the URL or a configured one
# request.body.read
# )
# unless Rack::Utils.secure_compare(expected_signature, header_signature)
# raise SignatureVerificationError, 'GitHub signature mismatch'
# end
# Reset request body for parsing if you read it here
request.body.rewind
end
end
Key takeaways from the controller:
skip_before_action :verify_authenticity_token: Webhooks don't send CSRF tokens, so you must disable this check.- Signature Verification: Crucial for security. Always verify the signature provided by the webhook sender.
- Respond Quickly: The controller's primary job is to receive the webhook, verify its authenticity, and enqueue a background job. It should respond with a
200 OKas quickly as possible (ideally within a few hundred milliseconds) to avoid sender retries. - Error Handling: Catch parsing errors, signature verification failures, and other exceptions. Return appropriate HTTP status codes (
400,401,500).
Next, the background jobs:
```ruby
app/jobs/stripe_webhook_job.rb
class StripeWebhookJob < ApplicationJob queue_as :default
def perform(event_data) # Reconstruct the Stripe Event object if needed, or work with the hash # event = Stripe::Event.construct_from(event_data)
# This is where idempotency logic will primarily live.
# For now, just log:
Rails.logger.info "Processing Stripe event: #{event_data['id']} of type #{event_data['type']}"
# Example: Handle a specific event type
case event_data['type']
when 'customer.created'
# Create a new customer record in your database
# Customer.find_or_create_by!(stripe_id: event_data['data']['object']['id']) do |customer|
# customer.email = event_data['data']['object']['email']
# end
when 'invoice.payment_succeeded'
# Update subscription status, create a payment record
# Payment.create_from_stripe_invoice(event_data['data']['object'])
end
# ... more event handling logic
end end
app/jobs/github_webhook_job.rb
class GithubWebhookJob < ApplicationJob queue_as :default
def perform(event_id, event_type, payload) Rails.logger.info "Processing GitHub event: #{event_id} of type #{event_type}"
# Example: Handle a 'push' event
case event_type
when 'push'
# Process the push event, e.g., update a repository status
# Repository.find_by(name: payload['repository']['full_name'])&.update_last_push(payload['after'])
when 'issues'
# Handle issue creation/update
# Issue.sync_from