Receiving webhooks in Rails
A webhook endpoint is the one route in a Rails application that a stranger is supposed to POST to.
No session, no CSRF token, no current_user, and a body your code is about to act on. Everything
that normally decides whether a request is allowed has been removed, and one HMAC comparison is
holding the door.
Four things have to be right, and they are independent: the signature has to be checked, it has to be checked against the bytes that arrived, the same event arriving twice must not do the work twice, and the response has to come back before the sender gives up. Getting three of the four is the normal outcome, and the missing one is usually silent. What follows was reproduced against stripe 19.6.2, actionpack 8.1.3.1 and PostgreSQL 17.7, with the output pasted as it came out.
Verify first, parse second
The ordering inside Stripe::Webhook.construct_event is deliberate, and the gem explains itself in
a comment at stripe-19.6.2/lib/stripe/webhook.rb:15:
def self.construct_event(payload, sig_header, secret,
tolerance: DEFAULT_TOLERANCE)
Signature.verify_header(payload, sig_header, secret, tolerance: tolerance)
# It's a good idea to parse the payload only after verifying it. We use
# `symbolize_names` so it would otherwise be technically possible to
# flood a target's memory if they were on an older version of Ruby that
# doesn't GC symbols. It also decreases the likelihood that we receive a
# bad payload that fails to parse and throws an exception.
_build_v1_event(payload)
end
verify_header runs on line 13, _build_v1_event on line 20. The symbol argument is historical,
and the general one is not: a before_action that parses is a before_action that ran your JSON
parser over bytes from a stranger, and every parser has had a bad year. Webhook signature
verification is a fixed amount of work on a fixed-size digest. Parsing is unbounded work on
whatever the sender felt like sending.
The practical consequence for a Rails webhook controller is that verification belongs before
anything touches params, and params is touched earlier than you think, which is the next
section.
The raw bytes, and what Rails does to them
The signature is computed over the body as a byte string. Any transformation on the way in changes the digest, and the failure is total rather than partial: one different byte and nothing matches.
Two Rails methods claim to give you the body, and only one of them is repeatable. From
actionpack-8.1.3.1/lib/action_dispatch/http/request.rb:362:
def body
if raw_post = get_header("RAW_POST_DATA")
raw_post = (+raw_post).force_encoding(Encoding::BINARY)
StringIO.new(raw_post)
else
body_stream
end
end
With RAW_POST_DATA already cached, body hands back a fresh StringIO every call. Without it,
body is rack.input itself, and reading a stream twice gets you the stream twice. Here is a
controller that reads it twice, once per content type:
A (json, params untouched): read1=28 read2=28 raw_post=28 read3=28
A (octet-stream): read1=28 read2=0 raw_post=28 read3=28
Same controller, same body, same 28 bytes on the wire. The JSON request works because Rails already
buffered the body to parse it into params; the application/octet-stream request has nothing
cached, so the second read returns the empty string. request.raw_post is the one that is always
28, because it calls read_body_stream, which rewinds around the read and caches the result.
The other way to lose the bytes is to rebuild them. params.to_unsafe_h.to_json on a body
containing a money amount and an escaped character:
raw_post : {
"id": "evt_1",
"amount": 19.90,
"note": "caf\u00e9"
}
reserialized: {"id":"evt_1","amount":19.9,"note":"café","hook":{"id":"evt_1","amount":19.9,"note":"café"}}
Three separate corruptions in one line. The trailing zero is gone because 19.90 and 19.9 are
the same Float. The é escape is gone because Ruby emits the character. And ParamsWrapper
has helpfully added a duplicate of the whole payload under the controller's own name. Sign over
that and nothing will ever verify.
One more caveat, for a service that is not on Puma. Rack 3 dropped the requirement that rack.input
be rewindable, and read_body_stream only rewinds if body_stream.respond_to?(:rewind). On a
server that streams request bodies, raw_post still returns the bytes once, and the middleware
that buys back the old behaviour is Rack::RewindableInput::Middleware.
What the signature is actually over
Every provider signs a string you have to reconstruct exactly, and the recipe is short enough to implement without a gem. Stripe's is the timestamp, a literal dot, and the body:
timestamped_payload = "#{timestamp.to_i}.#{payload}"
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, timestamped_payload)
That is webhook.rb:84, and reimplementing it by hand produces the same hex digest, which is worth
checking once so you believe the rest:
hmac = c7c4991c43c9f82bff25263af75c86284a1d9892cdeff11b9e33c09653a3c5bb
gem = c7c4991c43c9f82bff25263af75c86284a1d9892cdeff11b9e33c09653a3c5bb
GitHub does the same thing with a different envelope: HMAC-SHA256 over the raw body alone, sent as
X-Hub-Signature-256, with a sha256= prefix on the value. Shopify uses HMAC-SHA256 base64 encoded
in X-Shopify-Hmac-Sha256. The shape is always secret plus bytes, and the only real decisions are
what goes into the signed string and how the result is encoded.
Two details in the comparison are not optional. The first is constant-time comparison, because ==
on strings returns as soon as it finds a differing byte and that timing is a measurable oracle.
Rails ships one in ActiveSupport::SecurityUtils.secure_compare, which delegates to
OpenSSL.fixed_length_secure_compare when it is available; Rack ships Rack::Utils.secure_compare,
which does the same. The stripe gem carries its own pure-Ruby copy in Util.secure_compare,
borrowed from an older Active Support, and the effect is the same.
The second is that the header can legitimately carry more than one signature. Stripe's verifier
checks signatures.any? rather than a single value, and the reason is secret rotation: while a
rolled secret's predecessor is still live, "your endpoint has multiple active secrets and Stripe
generates one signature for each secret". A hand-rolled verifier that reads one value out of the
header will start failing halfway through a rotation you thought was zero-downtime.
Here is the whole thing as a concern, provider-neutral:
module VerifiesWebhookSignature
extend ActiveSupport::Concern
included do
skip_forgery_protection
before_action :verify_signature!
end
private
def verify_signature!
head :bad_request unless signature_matches?
end
def signature_matches?
received = request.headers["X-Signature-256"].to_s
return false if received.empty?
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, request.raw_post)
ActiveSupport::SecurityUtils.secure_compare(expected, received)
end
end
Two Rails defaults that answer before your code does
Nothing in the previous section runs if the request never reaches the action, and a stock Rails 8.1 application has two middlewares perfectly happy to reject a webhook on your behalf.
Forgery protection is the famous one. actionpack-8.1.3.1/lib/action_controller/railtie.rb:106
applies it to every
controller inheriting from ActionController::Base:
if app.config.action_controller.default_protect_from_forgery
protect_from_forgery with: :exception
end
A POST with no token to a controller without skip_forgery_protection raises
ActionController::InvalidAuthenticityToken: Can't verify CSRF token authenticity., which your
sender sees as a 422. An ActionController::API subclass never had the callback and needs no
exemption.
The quieter one is host authorization. Set config.hosts in production, as the generated
config/environments/production.rb encourages, and register the endpoint under a hostname that is
not in the list:
Host: app.example.com -> 200
Host: 203.0.113.9 -> 403
Host: old.example.com -> 403
An old domain kept alive for a redirect, or a provider posting to a bare IP, gets a 403 from
middleware, and the request never reaches a controller where you could log it. Redirects fail too:
Stripe's own status table says "We consider redirect responses to webhook requests as failures", so
a force_ssl upgrade from an http:// endpoint URL, or a trailing slash that your routes bounce,
is a permanent failure dressed as a 301.
All three failures look identical from the sending end, which is a non-2xx from a route that used
to work, and all three are invisible in production.log at default verbosity. When an integration
"stopped receiving events" after a deploy, this is the first place to look, before anything to do
with signatures.
A unique index is the idempotency check
Senders retry. Stripe "attempts to deliver events to your destination for up to three days with an exponential back off in live mode", and its own advice is to "guard against duplicated event receipts by logging the event IDs you've processed, and then not processing already-logged events". Which is right, and which almost everybody implements as a check followed by a write.
Two concurrent deliveries of one event, both asking exists? before either writes, against
PostgreSQL 17.7:
exists? guard, NO unique index : markers=2 charges=2
thread 0: ActiveRecord::RecordNotUnique
exists? guard, unique index : markers=1 charges=1
The exists? call contributed nothing in either run. Without the index, both threads read false and
both charged. The RecordNotUnique line belongs to the second run, where the loser's INSERT was
refused by PostgreSQL and the rescue absorbed it.
Webhook idempotency is a unique index, and the Ruby around it is a convenience.
So the ledger is a table and one index:
create_table :received_events do |t|
t.string :provider, null: false
t.string :external_id, null: false
t.jsonb :payload, null: false
t.datetime :processed_at
t.timestamps
end
add_index :received_events, [:provider, :external_id], unique: true
and the write is one method with the rescue as its control flow:
class ReceivedEvent < ApplicationRecord
# Returns the new row, or nil when this delivery has been seen before.
def self.record_once(provider:, external_id:, payload:)
create!(provider:, external_id:, payload:)
rescue ActiveRecord::RecordNotUnique
nil
end
end
provider is in the key because event IDs are only unique per sender, and two integrations both
using evt_ prefixes is a collision waiting for a quiet weekend.
One honest limit on the whole approach, which the sender documents and nobody reads: "In some cases,
two separate Event objects are generated and sent. To identify these duplicates, use the ID of the
object in data.object along with the event.type." An event-ID ledger deduplicates deliveries. It
does not deduplicate events, and no ledger can, because the two arrive with different IDs. Handlers
that must not double-apply still need to be idempotent in their own terms, by checking the state
they are about to write rather than trusting that they run once.
The savepoint a duplicate needs
Wrapping the marker and the work in one transaction is the right instinct: if the work fails, the marker rolls back with it, and the sender's next retry gets a clean attempt. Measured, with a handler that raises:
=== 4. handler raises: does the marker survive? ===
raised: downstream API 503
marker for evt_D present? false
retry after the failure: #<SideEffect id: 4, note: "retry worked">, side_effects=1
The subtlety is which kind of transaction. A unique violation is not an ordinary exception in PostgreSQL, it aborts the enclosing transaction, and every statement after it fails until the block ends. Run the duplicate-tolerant version inside an outer transaction without a savepoint:
=== 2. same thing nested inside an outer transaction, WITHOUT requires_new ===
delivery 1: #<SideEffect id: 2, note: "charged 0">
delivery 2: "SKIPPED"
outer blew up: ActiveRecord::StatementInvalid: PG::InFailedSqlTransaction:
ERROR: current transaction is aborted, commands ignored until end of transaction block
The rescue caught RecordNotUnique and reported the skip, and then the WebhookEvent.count at the
end of the outer transaction died anyway. requires_new: true emits a real SAVEPOINT, so the rollback is scoped to
the duplicate insert and the connection stays usable:
=== 3. same, WITH requires_new inside an outer transaction ===
delivery 1: #<SideEffect id: 3, note: "charged 0">
delivery 2: "SKIPPED (ActiveRecord::RecordNotUnique)"
can we still query? WebhookEvent.count = 2
Which is the whole argument for the option, and Transactions and rollback in Rails has the rest of what a savepoint costs. A webhook receiver is the most likely place in an application to hit this, because duplicate inserts are the design here rather than an edge case.
Replay protection is a clock, and it runs for five minutes
Signature verification proves the bytes came from the holder of the secret. It says nothing about
when, so a captured request replays forever unless something bounds its age. The bound is a
timestamp inside the signed string, and webhook.rb:154:
if tolerance && timestamp < Time.now - tolerance
with DEFAULT_TOLERANCE = 300. Reproduced:
timestamp 299s old : OK (no raise)
timestamp 301s old : Timestamp outside the tolerance zone (2026-09-24 16:30:30)
timestamp 10 YEARS in the FUTURE : OK (no raise)
The comparison is one-sided. A timestamp from the future is not outside the tolerance zone, because the only test is whether it is too old. Nobody can forge one without the secret, so this is not an exploit, but it does mean a sender whose clock has run away will be accepted indefinitely and your replay window is silently unbounded in one direction.
What the window does not do is deduplicate. Stripe "generates the timestamp and signature each time
we send an event to your endpoint. If Stripe retries an event (for example, your endpoint previously
replied with a non-2xx status code), then we generate a new signature and timestamp for the new
delivery attempt." The honest retry and the malicious replay differ only in that the retry is fresh.
The clock stops an attacker sitting on a captured request; the ledger from the previous section is
what stops the same event being applied twice. They are two mechanisms for two problems, and
shipping one of them feels like shipping both.
The tolerance argument that does the opposite of its documentation
Stripe's docs carry a warning worth quoting exactly: "Don't use a tolerance value of 0. Using a
tolerance value of 0 disables the recency check entirely."
In stripe-ruby 19.6.2 that is backwards, because 0 is truthy in Ruby and the guard is
if tolerance && .... A one-second-old timestamp, which is what a real delivery looks like:
tolerance: 0 with a 1s-old timestamp -> Stripe::SignatureVerificationError: Timestamp outside the tolerance zone
tolerance: nil with a 1s-old timestamp -> accepted
tolerance: 300 with a 1s-old timestamp -> accepted
Passing 0 does not relax the check, it rejects every event you will ever receive. nil is the
value that disables it. The warning is correct for the JavaScript and Python libraries, where zero
is falsy, and it is a trap in Ruby in the opposite direction. Leave the argument off.
Answer fast, and own the retry you just gave up
Both halves of the advice here come from the sender, and they pull against each other. Stripe says
your endpoint "must quickly return a successful status code (2xx) before any complex logic that
could cause a timeout", and separately says to "process incoming events with an asynchronous queue",
because "any large spike in webhook deliveries (for example, during the beginning of the month when
all subscriptions renew) might overwhelm your endpoint hosts". A timed-out delivery counts as a
failure and is retried for three days.
So the controller does no domain work at all:
class WebhooksController < ApplicationController
include VerifiesWebhookSignature
def create
event = ReceivedEvent.record_once(
provider: "acme",
external_id: request.headers["X-Event-Id"],
payload: JSON.parse(request.raw_post)
)
ProcessWebhookJob.perform_later(event.id) if event
head :ok
end
end
The if event is the deduplication: record_once rescues RecordNotUnique and returns nil, a
redelivery enqueues nothing, and the answer is still 200, because a duplicate is not an error and
answering 4xx to one buys you three days of retries for an event you already handled.
Two Rails-specific hazards sit in those few lines. The first is ordering: if the insert and the
enqueue are ever inside a transaction together, note that
ActiveJob::Base.enqueue_after_transaction_commit is false under load_defaults 8.1, despite
perform_later's own documentation describing deferral as the behaviour. Measured with the test
adapter:
enqueue_after_transaction_commit default = false
inside the transaction, enqueued jobs = 1
after rollback, enqueued jobs = 1
A job for work that never committed, now holding a row ID that does not exist. Set the flag to true on the job class and the enqueue follows the commit, which is what you want everywhere, not just here.
The second hazard is the one you accepted on purpose. The 200 is a promise that the event is safe,
and after it the sender's retry machinery is gone: a job that fails three times and dies leaves a
received_events row with a null processed_at and no one looking at it. Handling events
asynchronously means owning the redrive, which is one scheduled sweep over unprocessed rows older
than an hour. Solid Queue vs Sidekiq covers what the queue does
with the failure; the row is what tells you the failure existed.
What the boilerplate does, and where it stops
The Rails boilerplate this site sells verifies signatures and deduplicates, and it does the work
inline rather than in a job. WebhooksController is the entire endpoint:
def stripe
Billing::Webhooks::Handler.new(verified_event).call
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request
end
def verified_event
Stripe::Webhook.construct_event(
request.body.read,
request.headers["Stripe-Signature"],
AppConfig.stripe_webhook_secret
)
end
verified_event is evaluated before Handler#call runs, so nothing reaches a handler unverified,
and the two rescued classes map cleanly onto a 400. Idempotency lives in one model method
with the unique index doing the work and requires_new: true doing the savepoint, which is the
shape argued for above. Unknown event types return early and are never recorded, so adding a handler
later and resending the event still works.
Where it stops is the queue. The handlers are small and touch one or two rows, the billing traffic a
boilerplate serves on day one is not a renewal spike, and an inline handler keeps the sender's retry
as the error recovery instead of requiring a sweeper on day one. That is a defensible default and it
is not the right shape at volume. Note also request.body.read rather than request.raw_post. It
is correct here, because Stripe posts application/json and Rails has already buffered the body to
build params, and it is one content type away from returning the empty string.
The call, and what would change it
Verify in a before_action, over request.raw_post, with a constant-time comparison, and never
from params. Deduplicate on a unique index over (provider, external_id) and let
RecordNotUnique be the control flow. Answer 200 to anything you have accepted, including a
duplicate, and reserve non-2xx for a signature that did not check out. None of that is negotiable
and all of it is about forty lines.
Do the work inline until you have a reason not to, and treat the reason as a number rather than a principle: a handler that makes an external API call, or a delivery volume that arrives in bursts, or a p99 that has started to approach the sender's timeout. Until then the sender's three days of retries are free error handling, and a queue is a second place for the work to get lost.
What would change it: a Rails default of enqueue_after_transaction_commit = true would remove the
sharpest reason to be careful about ordering, and a provider that shipped a real idempotency key
distinct from the delivery ID would kill the "two Event objects for one thing" caveat that makes
handler-level idempotency necessary on top of the ledger.
The cost of recommending inline work is honest to state. An inline handler holds a web worker for the duration of whatever it calls, so one slow downstream API turns a renewal burst into a queue in front of Puma, and the symptom is timeouts on unrelated pages. The moment a handler talks to anything over the network, the argument flips.
What this post does not cover
Sending webhooks, which is a different problem with a different failure list: outbound retry schedules, endpoint health scoring, signing keys you have to let customers rotate, and SSRF checks on customer-supplied URLs.
Also absent: the standardwebhooks gem, version 1.1.0 released 2026-04-28, MIT, which implements
the Standard Webhooks specification for senders and receivers that adopt it and is worth a look if
you are designing an outbound integration, though it does not help with Stripe or GitHub, who
predate the spec. IP allowlisting, which Stripe recommends alongside webhook signature verification
and which is a firewall question rather than a Rails one. Replay attacks against an endpoint with no
timestamp in its signed payload at all, where the only defence left is the ledger. And any latency
figure, because the numbers above are byte counts, row counts and error strings, and those are the
ones that reproduce on your machine.
Comments
No comments yet. Be the first.