Stripe webhooks in Rails, and the idempotency the database owes you
September 11, 2026
A Stripe webhook endpoint is the strangest controller in a Rails app. It has no session, no signed
cookie, no CSRF token and no authenticated user, and it is nevertheless allowed to grant paid access
to your product. The billing hub explains why that
endpoint has to exist at all; this page is about making it safe.
Everything it is allowed to do rests on one check.
The signature is the only trust anchor
The endpoint is public. Anyone can POST to it, and a forged payload that says "this customer paid"
is trivial to write. What stops it is the Stripe webhook signature: a header, Stripe-Signature,
holding an HMAC that Stripe computed over the exact bytes of the request body using a secret only
the two of you hold.
Both opt-outs at the top are deliberate and neither is a shortcut. allow_unauthenticated_access
because Stripe has no account here, and skip_forgery_protection because CSRF protection defends a
browser session that this request does not have. The signature replaces both.
Why the raw body, and not the parsed params
request.body.read rather than params, and the distinction is not stylistic. The signature was
computed over the exact bytes Stripe sent. Parse that JSON and re-serialise it and you may get
identical data with different bytes: a reordered key, a different float rendering, a dropped space.
The data is the same and the HMAC is not, so verification fails with no clue as to why.
Read the body first, verify, and only then work with the parsed event that construct_event
returns.
Answering 400 rather than letting it raise
A bad signature is not a server error, and the status code you return decides Stripe's behaviour.
A 500 tells Stripe the delivery failed and to try again, so a forged request becomes a retry loop
you serve indefinitely. A 400 tells Stripe the request itself was unacceptable, and it stops. The
rescue covers Stripe::SignatureVerificationError and JSON::ParserError together because both
mean the same thing from your side: this payload is not something Stripe sent.
Stripe retries, so the handler runs more than once
Stripe retries until it receives a 2xx. That schedule runs for days, which means a handler that
grants a month of access will grant it repeatedly unless something stops it, and a handler that
records a transaction will record it several times.
There is a second, less obvious source of duplication. Your handler can succeed, and the response
can be lost on the way back. Stripe never sees the 2xx, and delivers again. No bug on either side,
the same event twice.
So rails webhook idempotency is not a refinement for later. It is the condition under which the
endpoint is correct at all.
Where the guarantee actually lives
The tempting implementation is a StripeEvent model with validates :stripe_id, uniqueness: true.
It looks right and it does not hold, because a Rails uniqueness validation is a SELECT followed by
an INSERT. Two concurrent deliveries of the same event both run the SELECT, both find nothing, and
both insert.
The insert is attempted first and the work runs only if it succeeded. A second delivery hits the
index, raises ActiveRecord::RecordNotUnique, and returns false without running anything. No
check-then-act window exists, because there is no check: the database decides.
The savepoint, and why the marker rolls back with the work
requires_new: true is the part most implementations leave out, and it is what separates a
retryable failure from a silently swallowed one.
It opens a real savepoint, so the marker and the work commit or roll back together. If the handler
raises halfway through granting access, the StripeEvent row disappears with it, and Stripe's next
delivery finds no marker and gets a genuine second attempt.
Without the savepoint the marker can survive a failed handler. The event is then recorded as
processed, the work never happened, and every retry Stripe sends is refused by the marker. The
customer has paid and has nothing, and the logs say the event was handled.
An unknown event type returns early and answers 200. That is deliberate: Stripe sends event types
you never subscribed to and adds new ones over time, and raising on them would turn Stripe's product
roadmap into your incident pager.
Note that created and updated share one handler. Stripe does not promise ordering, so treating
the two as different instructions means the older snapshot can overwrite the newer one. Treating both
as "here is the subscription as it stands" removes the ordering assumption entirely.
A second layer where the first one is not enough
process_once protects against the same event arriving twice. It does not protect against two
different events describing the same payment, which Stripe will also do.
So the payment handler keys on the Stripe object rather than on the event:
Two layers, on two different keys, because they answer two different questions. One asks "have I
seen this delivery", the other asks "do I already have this payment".
What this codebase does around Stripe webhooks
The endpoint verifies before it parses, answers 400 on anything unsigned, and hands a verified event
to a handler table that ignores what it does not know. Idempotency is delegated once, at the routing
layer, so no individual handler has to remember it, and the savepoint means a handler that raises
leaves the event retryable rather than marked done. The subscription handlers upsert rather than
branch on event type, and the payment handler carries its own uniqueness on the Stripe id, for the
case the event ledger cannot see.
A Stripe webhook endpoint is the strangest controller in a Rails app. It has no session, no signed cookie, no CSRF token and no authenticated user, and it is nevertheless allowed to grant paid access to your product. The billing hub explains why that endpoint has to exist at all; this page is about making it safe.
Everything it is allowed to do rests on one check.
The signature is the only trust anchor
The endpoint is public. Anyone can POST to it, and a forged payload that says "this customer paid" is trivial to write. What stops it is the Stripe webhook signature: a header,
Stripe-Signature, holding an HMAC that Stripe computed over the exact bytes of the request body using a secret only the two of you hold.Both opt-outs at the top are deliberate and neither is a shortcut.
allow_unauthenticated_accessbecause Stripe has no account here, andskip_forgery_protectionbecause CSRF protection defends a browser session that this request does not have. The signature replaces both.Why the raw body, and not the parsed params
request.body.readrather thanparams, and the distinction is not stylistic. The signature was computed over the exact bytes Stripe sent. Parse that JSON and re-serialise it and you may get identical data with different bytes: a reordered key, a different float rendering, a dropped space. The data is the same and the HMAC is not, so verification fails with no clue as to why.Read the body first, verify, and only then work with the parsed event that
construct_eventreturns.Answering 400 rather than letting it raise
A bad signature is not a server error, and the status code you return decides Stripe's behaviour.
A 500 tells Stripe the delivery failed and to try again, so a forged request becomes a retry loop you serve indefinitely. A 400 tells Stripe the request itself was unacceptable, and it stops. The rescue covers
Stripe::SignatureVerificationErrorandJSON::ParserErrortogether because both mean the same thing from your side: this payload is not something Stripe sent.Stripe retries, so the handler runs more than once
Stripe retries until it receives a 2xx. That schedule runs for days, which means a handler that grants a month of access will grant it repeatedly unless something stops it, and a handler that records a transaction will record it several times.
There is a second, less obvious source of duplication. Your handler can succeed, and the response can be lost on the way back. Stripe never sees the 2xx, and delivers again. No bug on either side, the same event twice.
So rails webhook idempotency is not a refinement for later. It is the condition under which the endpoint is correct at all.
Where the guarantee actually lives
The tempting implementation is a
StripeEventmodel withvalidates :stripe_id, uniqueness: true. It looks right and it does not hold, because a Rails uniqueness validation is a SELECT followed by an INSERT. Two concurrent deliveries of the same event both run the SELECT, both find nothing, and both insert.The guarantee belongs to the database:
The insert is attempted first and the work runs only if it succeeded. A second delivery hits the index, raises
ActiveRecord::RecordNotUnique, and returnsfalsewithout running anything. No check-then-act window exists, because there is no check: the database decides.The savepoint, and why the marker rolls back with the work
requires_new: trueis the part most implementations leave out, and it is what separates a retryable failure from a silently swallowed one.It opens a real savepoint, so the marker and the work commit or roll back together. If the handler raises halfway through granting access, the
StripeEventrow disappears with it, and Stripe's next delivery finds no marker and gets a genuine second attempt.Without the savepoint the marker can survive a failed handler. The event is then recorded as processed, the work never happened, and every retry Stripe sends is refused by the marker. The customer has paid and has nothing, and the logs say the event was handled.
Routing an event to exactly one handler
An unknown event type returns early and answers 200. That is deliberate: Stripe sends event types you never subscribed to and adds new ones over time, and raising on them would turn Stripe's product roadmap into your incident pager.
Note that
createdandupdatedshare one handler. Stripe does not promise ordering, so treating the two as different instructions means the older snapshot can overwrite the newer one. Treating both as "here is the subscription as it stands" removes the ordering assumption entirely.A second layer where the first one is not enough
process_onceprotects against the same event arriving twice. It does not protect against two different events describing the same payment, which Stripe will also do.So the payment handler keys on the Stripe object rather than on the event:
Two layers, on two different keys, because they answer two different questions. One asks "have I seen this delivery", the other asks "do I already have this payment".
What this codebase does around Stripe webhooks
The endpoint verifies before it parses, answers 400 on anything unsigned, and hands a verified event to a handler table that ignores what it does not know. Idempotency is delegated once, at the routing layer, so no individual handler has to remember it, and the savepoint means a handler that raises leaves the event retryable rather than marked done. The subscription handlers upsert rather than branch on event type, and the payment handler carries its own uniqueness on the Stripe id, for the case the event ledger cannot see.