How to Receive SendGrid Webhooks in Ruby on Rails
Skip CSRF + read request.raw_post for HMAC-friendly handlers
Rails remains the canonical Ruby web framework for SaaS. Its built-in CSRF protection is great for forms but actively blocks webhook handlers — you have to disable it on the webhook route AND read request.raw_post to get the unparsed bytes that HMAC verification needs. This guide walks through the Ruby on Rails setup for SendGrid webhooks end to end: capturing the raw body, verifying the signature, handling retries idempotently, and iterating locally without redeploying. Cross-reference the SendGrid Webhooks overview for the event catalog and sample payload.
SendGrid Official Webhook Docs1. Set Up the Ruby on Rails Endpoint
The endpoint needs to do three things, in this order: read the raw body, verify the signature against those exact bytes, and only then parse the JSON for your business logic.
# config/routes.rb
Rails.application.routes.draw do
post '/webhooks/:service', to: 'webhooks#receive', as: :webhook
end
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
# CSRF tokens don't apply to programmatic webhook deliveries.
skip_before_action :verify_authenticity_token
def receive
raw_body = request.raw_post # raw bytes — NOT parsed by Rails
signature = request.headers['X-Signature-Header']
# 1. Verify HMAC over raw_body
# 2. Parse JSON only after verification passes
# 3. Process the event idempotently (use the event id as your key)
event = JSON.parse(raw_body)
Rails.logger.info "Verified webhook: #{event['type'] || event}"
head :ok
end
end2. Verify the SendGrid Signature
- Algorithm
- ECDSA (P-256, SHA-256)
- Header
X-Twilio-Email-Event-Webhook-Signature- Encoding
- base64
SendGrid signs `{X-Twilio-Email-Event-Webhook-Timestamp}{raw_request_body}` with an ECDSA private key. Verify with the public key from Settings → Mail Settings → Event Webhooks (must be enabled per-webhook).
Ruby verification
# Gemfile: gem 'sendgrid-ruby'
require 'sendgrid-ruby'
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:sendgrid]
def sendgrid
payload = request.raw_post
signature = request.headers['X-Twilio-Email-Event-Webhook-Signature'].to_s
timestamp = request.headers['X-Twilio-Email-Event-Webhook-Timestamp'].to_s
ew = SendGrid::EventWebhook.new
public_key = ew.convert_public_key_to_ecdsa(
ENV['SENDGRID_WEBHOOK_PUBLIC_KEY'],
)
unless ew.verify_signature(public_key, payload, signature, timestamp)
return head :forbidden
end
events = JSON.parse(payload)
render json: { ok: true, count: events.length }
end
endWire this verification call into the Ruby on Rails handler from section 1. The pattern is identical across Ruby on Rails versions: read raw body, verify, parse JSON, dispatch.
See SendGrid's official signing docs for the canonical reference, or the cross-service signature verification guide for the same pattern in Ruby and other languages.
3. Make the Handler Idempotent
SendGrid can — and will — send the same event twice. Network blips, your server returning a 5xx mid-processing, deploy windows: any of these triggers a retry, and your handler will see the same event id again. Build for that on day one rather than chasing duplicate-charge bugs in production.
The simplest pattern is a unique constraint on the event id in your database. The handler does the work inside a transaction, and the insert into the events table is the last step — if a retry arrives, the unique-constraint violation tells you the event already committed and you can return 200 without re-running the side effects.
Pattern in any framework:
- Read raw body, verify signature.
- Begin transaction.
- Apply business logic (charge, fulfil, notify, etc.).
- Insert event id into
processed_eventswith a unique constraint. - Commit. Return 200.
- On unique-constraint violation, return 200 — the event was already processed by a prior delivery.
4. SendGrid Retry Behaviour
- Max attempts
- Up to 24h of retries
- Total window
- 24 hours
- Backoff
- Exponential
- Retries on
- Non-2xx responses
- Stops on
- Any 2xx response within timeout
Combine the retry numbers above with the idempotency pattern in section 3: aim to acknowledge fast (return 200 under the timeout) and let the idempotency table absorb any duplicates from in-flight retries. The full pattern, including dead-letter queues and replay-from-capture, lives in the Webhook Retry Strategies guide.
5. Test Locally Without Deploying
The fastest iteration loop for any webhook handler is: capture a real SendGrid event with HookRay, then replay that captured request against your local Ruby on Rails server until the verification + business logic both pass. No need to retrigger the event in SendGrid, no need to redeploy.
- Get a free webhook URL at hookray.com — no signup.
- Paste the URL into your SendGriddashboard's webhook settings.
- Trigger a test event. HookRay shows the headers, raw body, and parsed payload in real time.
- Use HookRay's replay feature to send the captured request against
http://localhost:3000/api/webhooks/sendgrid(or wherever your Ruby on Rails app is listening) — iterate on your code without re-poking the SendGrid dashboard.
Deploying the Ruby on Rails Handler
Rails apps run well on long-running containers (Render, Fly.io, Heroku, Kamal-deployed VPS). Avoid hosting Rails as serverless functions for webhook endpoints — boot time is unforgiving and Rails warm-up easily blows past the 5-15 second window most webhook senders give you before retrying.
Need a host that boots quickly enough to absorb webhook bursts? DigitalOcean droplets stay warm, support raw-body proxies cleanly, and avoid the cold-start traps of some serverless runtimes.
Capture a real SendGrid webhook in 30 seconds
Free webhook URL, real-time payload inspection, one-click replay. No signup required.
Start Testing — Free