Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Ruby on Rails

To handle Paystack webhooks in Rails, add skip_forgery_protection to your webhook action, read request.body.read for the raw bytes, compute an HMAC SHA-512 hash with your secret key, compare it to the X-Paystack-Signature header, and process the event. Return head :ok.

Webhook Controller

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_forgery_protection only: :paystack

  def paystack
    signature = request.headers['X-Paystack-Signature']
    return head :bad_request unless signature.present?

    body = request.body.read
    secret = ENV['PAYSTACK_SECRET_KEY']

    computed_hash = OpenSSL::HMAC.hexdigest('sha512', secret, body)

    unless ActiveSupport::SecurityUtils.secure_compare(computed_hash, signature)
      return head :bad_request
    end

    event = JSON.parse(body)

    ProcessPaystackWebhookJob.perform_later(event)

    head :ok
  end
end

ActiveJob for Processing

# app/jobs/process_paystack_webhook_job.rb
class ProcessPaystackWebhookJob < ApplicationJob
  queue_as :default

  def perform(event)
    event_type = event['event']
    data = event['data'] || {}

    case event_type
    when 'charge.success'
      PaystackService.new.verify_and_fulfill(data['reference']) if data['reference']
    when 'subscription.create'
      handle_subscription_create(data)
    when 'invoice.payment_failed'
      handle_invoice_failed(data)
    end
  end

  private

  def handle_subscription_create(data)
    # Create or update subscription record
  end

  def handle_invoice_failed(data)
    # Update subscription status to past_due
  end
end

Route

# config/routes.rb
post '/webhooks/paystack', to: 'webhooks#paystack'

Testing

# test/controllers/webhooks_controller_test.rb
test 'valid webhook returns 200' do
  payload = { event: 'charge.success', data: { reference: 'test_123' } }.to_json
  signature = OpenSSL::HMAC.hexdigest('sha512', ENV['PAYSTACK_SECRET_KEY'], payload)

  post webhooks_paystack_url,
    params: payload,
    headers: { 'Content-Type' => 'application/json', 'X-Paystack-Signature' => signature }

  assert_response :ok
end

Production Notes

  1. Use Sidekiq or another background job backend. Do not use inline jobs in production.
  2. HTTPS required. Your webhook URL must use HTTPS.
  3. Log events. Store every webhook in a database for debugging.
  4. Monitor. Check Paystack dashboard for failed deliveries.

Ship Payments Faster

Key Takeaways

  • Add skip_forgery_protection to your webhook controller action.
  • Use request.body.read for the raw bytes needed for HMAC verification.
  • Use OpenSSL::HMAC.hexdigest for SHA-512 hash computation.
  • Use ActiveSupport::SecurityUtils.secure_compare for constant-time comparison.
  • Return head :ok immediately. Process events in ActiveJob for reliability.
  • Make handlers idempotent. Paystack retries failed deliveries.

Frequently Asked Questions

Why use skip_forgery_protection instead of protect_from_forgery?
skip_forgery_protection is the Rails 7+ way to disable CSRF for specific actions. Paystack webhook requests do not include Rails CSRF tokens.
Can I use request.raw_post instead of request.body.read?
Yes. request.raw_post also returns the raw request body as a string. Both work for HMAC verification.
Should I use ActiveJob or Sidekiq directly?
ActiveJob is the Rails abstraction. Use it with Sidekiq as the backend adapter. This keeps your code framework-agnostic while getting Sidekiq performance.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon