Handling Stripe Webhooks in Rails Without Double Fulfilling or Missing a Payment

The first version of most Stripe integrations works like this. The customer pays, Stripe redirects them to a thank you page, and the thank you page marks the order as paid. It works in every test, and it keeps working until the day a customer closes the tab right after paying, or their phone loses signal during the redirect. Now you have a charge in Stripe and an order that still says pending.

Webhooks fix that, because Stripe tells your server what happened whether or not the customer’s browser cooperates. But webhooks bring their own traps. This is how I build handlers in Rails so that payments are never lost and orders are never fulfilled twice.

Treat the webhook as the source of truth

The redirect is a courtesy for the customer. The webhook is the record. Mark an order paid when you receive the right event from Stripe, not when someone lands on a page. The thank you page can show a friendly message such as “we are confirming your payment” and update when the webhook has done its work.

For most ecommerce flows, the events to listen for are these:

  • checkout.session.completed when you use Stripe Checkout, which tells you the session finished.
  • payment_intent.succeeded when the money has actually been captured.
  • payment_intent.payment_failed when a payment attempt fails and you want to follow up.
  • charge.refunded so that refunds started in the Stripe dashboard are reflected in your system.
  • charge.dispute.created so that someone hears about a chargeback quickly.

Subscribe only to the events you handle. A smaller list means fewer surprises.

Verify every request

Anyone who knows your webhook URL can send fake events to it. Stripe signs each request, and the signature is included in a header. Your handler must verify it with the signing secret for that endpoint before trusting anything in the body.

The most common Rails mistake here is verifying against the wrong thing. The signature is calculated over the exact raw body that Stripe sent. If you let Rails parse the body and rebuild it, even a harmless change in spacing breaks the check. Read the raw request body, pass it with the header to the Stripe library’s verification method, and reject the request if it fails. Use a different secret for test mode and live mode, and keep them in your credentials store, not in your code.

Answer quickly, work later

Stripe expects a prompt success response. If your handler calls three other services before answering, it will eventually time out, and Stripe will treat that as a failure and try again later. Retries in live mode continue for a few days with growing delays, so check the current documentation for the exact schedule.

So the handler should do three things: verify the signature, store the event, and enqueue a background job. Then answer with a success status. The real work, such as updating an order, sending an email, and telling the warehouse, happens in a Sidekiq job. I go through the details of making those jobs safe in Sidekiq Retries and Idempotent Jobs for Ecommerce.

Make duplicates harmless

Stripe can send the same event more than once, and your own retries add to that. Every event has a unique ID, so keep a table of processed events with a unique database index on that ID.

The flow is simple:

  • When an event arrives, try to insert its ID into the table.
  • If the insert fails because the ID already exists, you have seen this event before. Answer with success and stop.
  • If it succeeds, continue and process it, preferably inside the same database transaction as the changes it causes.

Doing the insert and the order update together means a crash halfway through cannot leave you in a state where the event is marked as handled but the order never changed.

Also guard the business action, not only the event. Even with a clean event table, write the fulfillment step so that it checks the current order status first. If the order is already paid and queued, a second “paid” signal does nothing. That second layer protects you from a bug in the first.

Do not rely on event order

Events do not arrive in a guaranteed sequence. A refund event could, in rare cases, reach you before the success event it relates to. If your code assumes order, it will fail at the worst possible moment.

The calm answer is to treat each event as a hint that something changed, and then ask Stripe for the current state of the payment or session when you need to decide. The object you fetch is always up to date, even if the events were shuffled. It costs one extra API call and removes a whole category of bugs.

Use idempotency keys on your own requests

The other direction matters too. When your server asks Stripe to create a charge, a refund or a customer, a network failure can leave you unsure whether the request worked. If you simply try again, you might create a second one.

Stripe supports idempotency keys for this. You send a unique key with the request, and if you repeat the request with the same key, Stripe returns the original result instead of doing it again. Build the key from something stable, such as your own order ID and the action, so a retry produces the same key.

Test with the tools Stripe gives you

You do not need to make real payments to test a handler. The Stripe command line tool can forward test events to your local server and can trigger specific ones on demand. Use it to try the awkward cases on purpose:

  • Send the same event twice and confirm nothing happens the second time.
  • Send a payment failure and confirm your follow up works.
  • Stop your Sidekiq workers, send events, restart them, and confirm everything gets processed.
  • Send a request with a bad signature and confirm it is rejected.

Write these as automated tests if you can. They are the ones you will be glad of during an incident.

Add a safety net

Even a perfect handler can miss something, for example if your server was down for longer than Stripe keeps retrying. Add a scheduled job that looks at payments from the last day or two and compares them with your orders. Anything that Stripe says succeeded and your system does not know about gets handled the same way as a webhook would.

This is the same pattern I use for Shopify orders, and it is described in Shopify Webhooks Missing Orders. It turns “we think we got everything” into something you can actually check.

Be ready when it goes wrong

Finally, make failures visible. Log each event ID with what you did about it. Alert when jobs fail after their retries, and when no events have arrived for longer than is normal for your store. A quiet webhook endpoint is sometimes a quiet day, and sometimes it is a broken configuration after a deploy.

I have spent a lot of time around payment integrations, including a Ruby gem I wrote for a payment gateway, which I describe in Building the Zenti Ruby Gem. The lesson that repeats is that money flows deserve more care than almost any other part of the system, and the care is mostly boring: signatures, unique keys, retries and reports.

If you are running Stripe on Rails and are unsure whether your handler would survive a bad day, email kevin@kevinhq.com and describe how orders get marked as paid today. I will tell you what I would check first.


← Back to Homepage


Get occasional breakdowns

When I find something worth writing up — a fix, a pattern I keep seeing in client stores, something that actually moved revenue — I send it out. No schedule. No filler.