How to Integrate Stripe Payment Intent Webhooks in Rails

- Use
payment_intent.succeeded and payment_intent.-
payment_failed to track successful and failed Payment Intents.- Always verify the
Stripe-Signature header against the unmodified request body before trusting an event.- Store the Stripe event ID with a unique database index so automatic retries cannot process the same event twice.
- Return a successful response for duplicate or intentionally ignored events. Return an error when processing should be retried.
- Stripe does not guarantee event delivery order, so avoid allowing an older failure event to overwrite a newer successful status.
- Create the local payment record before confirming the Payment Intent so the webhook can locate it.
- Keep webhook responses fast. Send emails, fulfil orders, and perform other slow work through background jobs.
A customer returning to your success page does not prove that their payment succeeded. They might close the browser, lose their connection, or complete a payment method that remains pending after checkout.
Stripe webhooks solve this problem by notifying your Rails application whenever a Payment Intent changes. Your server can then update its payment records independently of the browser.
While implementing Stripe webhooks, we have found that receiving the request is the easy part. Production reliability depends on verifying the raw payload, preventing duplicate processing, handling retries, and avoiding assumptions about event order.
What Is a Webhook?
A webhook is an HTTP callback sent automatically when an event occurs in another system.
In a traditional API interaction, your application initiates a request and waits for the provider’s response. With a webhook, the external provider initiates an HTTP request to your application.
For example, Stripe can send a POST request when:
- A Payment Intent succeeds
- A payment attempt fails
- A charge is refunded
- A subscription changes
- A dispute is created
Your Rails application exposes a public endpoint that receives these requests and performs the corresponding internal action.
Webhooks remove the need to repeatedly poll Stripe for payment status. They are also more reliable than depending on client-side redirects because the customer does not need to keep the browser or application open.
Why Stripe Payment Intents Need Webhooks
A Payment Intent represents the lifecycle of a payment. It can move through statuses such as:
requires_payment_method
requires_confirmation
requires_action
processing
succeeded
canceledSome payment methods complete immediately, while others remain in processing for longer. Customer authentication can also interrupt the browser flow.
Stripe therefore recommends monitoring payment status on the server through webhooks instead of fulfilling an order from the client. The primary events are:
payment_intent.succeeded
payment_intent.payment_failedStripe sends the complete Payment Intent object inside the event payload, allowing the application to identify the payment and update its local state.
How Stripe Webhook Delivery Works
The complete flow is:
- Your application creates a Payment Intent.
- The local payment record stores the Stripe Payment Intent ID.
- The customer attempts the payment.
- Stripe updates the Payment Intent.
- Stripe sends an event to your webhook endpoint.
- Rails verifies the event signature.
- The application checks whether the event was already processed.
- The local payment record is updated.
- Rails returns a successful HTTP response.
If Stripe does not receive a successful response, it automatically retries delivery. In live mode, Stripe can retry an undelivered event for up to three days with an increasing delay between attempts.
This retry behaviour makes webhooks resilient, but it also means your handler must be idempotent.
Prerequisites
This guide assumes you already have:
- A Rails application
- A Stripe account
- A Payment Intent integration
- PostgreSQL or another database with JSON support
- A local
Paymentmodel - The Stripe CLI for local testing
The examples use jsonb, which is available in PostgreSQL. Use your database’s corresponding JSON column type when working with a different database.
Step 1: Install the Stripe Ruby Gem
Add Stripe to your Gemfile:
gem "stripe"Install the dependency:
bundle installIf the application also creates Payment Intents through the Stripe API, configure the secret API key in an initializer:
# config/initializers/stripe.rb
Stripe.api_key =
Rails.application.credentials.dig(:stripe, :secret_key) ||
ENV["STRIPE_SECRET_KEY"]The webhook signing secret is different from the Stripe API secret key. Do not use one in place of the other.
Step 2: Prepare the Payment Model
The webhook needs a reliable way to associate a Stripe Payment Intent with a local record.
A minimal payments migration could include:
class CreatePayments < ActiveRecord::Migration[7.1]
def change
create_table :payments do |t|
t.string :stripe_payment_intent_id, null: false
t.string :status, null: false, default: "pending"
t.bigint :amount
t.string :currency
t.string :failure_message
t.bigint :last_stripe_event_created_at
t.timestamps
end
add_index :payments,
:stripe_payment_intent_id,
unique: true
end
endRun the migration:
bin/rails db:migrateIf your application already has a payment or order model, add only the missing Stripe fields instead of creating another table.
The last_stripe_event_created_at column helps prevent an older event from overwriting the state applied by a newer one.
Create the local record as soon as the Payment Intent is created:
payment_intent = Stripe::PaymentIntent.create(
amount: 2_000,
currency: "usd",
automatic_payment_methods: {
enabled: true
}
)
Payment.create!(
stripe_payment_intent_id: payment_intent.id,
status: payment_intent.status,
amount: payment_intent.amount,
currency: payment_intent.currency
)Amounts in Stripe are generally represented in the currency’s smallest unit. For example, 2_000 USD represents $20.00.
Step 3: Create a Webhook Event Model
Stripe may deliver the same event more than once. Store the Stripe event ID and enforce uniqueness at the database level.
Generate the model:
bin/rails generate model StripeWebhookEvent \
stripe_event_id:string \
event_type:string \
payload:jsonb \
processed_at:datetimeUpdate the generated migration:
class CreateStripeWebhookEvents < ActiveRecord::Migration[7.1]
def change
create_table :stripe_webhook_events do |t|
t.string :stripe_event_id, null: false
t.string :event_type, null: false
t.jsonb :payload, null: false, default: {}
t.datetime :processed_at
t.timestamps
end
add_index :stripe_webhook_events,
:stripe_event_id,
unique: true
end
endRun the migration:
bin/rails db:migrateThe unique index is important. An application-level check such as exists? is not enough because two identical deliveries could be processed concurrently before either request creates the database record.
Let’s Build Your Web App Together!
We build fast, scalable, and secure web applications that help your business grow. From idea to launch, we handle it all.
Step 4: Add the Webhook Route
Add a dedicated route in config/routes.rb:
Rails.application.routes.draw do
post "/stripe/webhooks",
to: "stripe_webhooks#create"
endThe resulting endpoint is:
POST /stripe/webhooksA singular POST route is clearer than exposing REST actions that Stripe will never call.
Step 5: Create the Webhook Controller
Create app/controllers/stripe_webhooks_controller.rb:
class StripeWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token,
only: :create,
raise: false
def create
payload = request.raw_post
signature = request.headers["Stripe-Signature"]
event = Stripe::Webhook.construct_event(
payload,
signature,
webhook_secret
)
process_event(event, payload)
head :ok
rescue JSON::ParserError
render json: { error: "Invalid payload" },
status: :bad_request
rescue Stripe::SignatureVerificationError
render json: { error: "Invalid signature" },
status: :bad_request
rescue ActiveRecord::RecordNotUnique
# Stripe retried an event that was already processed.
head :ok
rescue StandardError => error
Rails.logger.error(
"Stripe webhook failed: #{error.class}: #{error.message}"
)
head :internal_server_error
end
private
def process_event(event, payload)
StripeWebhookEvent.transaction do
webhook_event = StripeWebhookEvent.create!(
stripe_event_id: event.id,
event_type: event.type,
payload: JSON.parse(payload)
)
case event.type
when "payment_intent.succeeded"
handle_payment_succeeded(event)
when "payment_intent.payment_failed"
handle_payment_failed(event)
else
Rails.logger.info(
"Ignoring Stripe event: #{event.type}"
)
end
webhook_event.update!(processed_at: Time.current)
end
end
def handle_payment_succeeded(event)
payment_intent = event.data.object
payment = find_payment(payment_intent.id)
return unless payment
return if stale_event?(payment, event)
payment.update!(
status: "succeeded",
failure_message: nil,
last_stripe_event_created_at: event.created
)
PaymentFulfillmentJob.perform_later(payment.id)
end
def handle_payment_failed(event)
payment_intent = event.data.object
payment = find_payment(payment_intent.id)
return unless payment
return if stale_event?(payment, event)
payment.update!(
status: "failed",
failure_message:
payment_intent.last_payment_error&.message,
last_stripe_event_created_at: event.created
)
end
def find_payment(payment_intent_id)
Payment.find_by(
stripe_payment_intent_id: payment_intent_id
).tap do |payment|
unless payment
Rails.logger.warn(
"No payment found for #{payment_intent_id}"
)
end
end
end
def stale_event?(payment, event)
previous_timestamp =
payment.last_stripe_event_created_at
previous_timestamp.present? &&
event.created < previous_timestamp
end
def webhook_secret
Rails.application.credentials.dig(
:stripe,
:webhook_secret
) || ENV.fetch("STRIPE_WEBHOOK_SECRET")
end
endThis controller performs several essential checks:
- Reads the unmodified request body
- Verifies the Stripe signature
- Stores the Stripe event ID
- Prevents duplicate processing
- Handles only relevant event types
- Avoids overwriting newer payment data with an older event
- Sends slow fulfilment work to a background job
- Returns an error when processing unexpectedly fails
The Stripe webhook documentation specifically requires the raw request body for signature verification. Parsing or changing the payload before verification can invalidate the signature.
Why Is CSRF Verification Skipped?
Rails normally expects a CSRF token for state-changing requests from browser sessions. Stripe cannot provide a Rails CSRF token because the request originates from Stripe’s servers.
The webhook action therefore skips Rails CSRF verification:
skip_before_action :verify_authenticity_token,
only: :createThis does not mean the endpoint is left unverified. The Stripe-Signature header and webhook signing secret authenticate the request.
Do not disable CSRF protection for the entire application.
Step 6: Create the Fulfilment Job
Tasks such as sending emails, activating subscriptions, creating invoices, or provisioning access should not delay the webhook response.
Generate a job:
bin/rails generate job PaymentFulfillmentUpdate the generated class:
class PaymentFulfillmentJob < ApplicationJob
queue_as :default
def perform(payment_id)
payment = Payment.find(payment_id)
return unless payment.status == "succeeded"
return if payment.fulfilled_at.present?
# Deliver the purchased product or service here.
payment.update!(fulfilled_at: Time.current)
end
endThis example assumes the payments table has a fulfilled_at column. Add it if fulfilment is part of your workflow:
bin/rails generate migration \
AddFulfilledAtToPayments \
fulfilled_at:datetime
bin/rails db:migrateChecking fulfilled_at makes the job idempotent. If the job runs twice, the purchased product or service is not delivered twice.
For production, configure a persistent Active Job backend such as Sidekiq, Solid Queue, or another supported queue.
Step 7: Store the Webhook Signing Secret
For local development, the secret can be stored in an environment variable:
export STRIPE_WEBHOOK_SECRET="whsec_replace_me"For Rails encrypted credentials:
bin/rails credentials:editAdd:
stripe:
secret_key: sk_test_replace_me
webhook_secret: whsec_replace_meNever place these values directly in controller code or commit them to source control.
Also remember that the Stripe CLI signing secret and Dashboard endpoint signing secret are different. Use the secret generated for the environment that sends the webhook.
Step 8: Test the Webhook Locally
Install and authenticate the Stripe CLI, then forward Stripe events to Rails:
stripe listen \
--forward-to localhost:3000/stripe/webhooksThe command displays a signing secret similar to:
whsec_...Set that value as STRIPE_WEBHOOK_SECRET for the local Rails server.
Trigger a successful Payment Intent event:
stripe trigger payment_intent.succeededTrigger a failed payment event:
stripe trigger payment_intent.payment_failedThe Stripe CLI generates test Payment Intents that may not exist in your local payments table. The controller will verify and store those events but log that no matching payment was found.
To test the complete database update, create and confirm a real test-mode Payment Intent through your application so its ID is stored locally before Stripe sends the event.
Step 9: Register the Production Endpoint
Deploy the application with HTTPS and open the webhook settings in Stripe Workbench or the Stripe Dashboard.
Register the production URL:
https://example.com/stripe/webhooksSubscribe only to the events the application handles:
payment_intent.succeeded
payment_intent.payment_failedSelecting every available Stripe event increases traffic, storage, log volume, and the number of unexpected event types your application must inspect.
Reveal the endpoint’s signing secret and store it securely in the production environment. Test-mode and live-mode webhook endpoints have different secrets.
Signature Verification vs Idempotency
Signature verification and idempotency solve different problems.
Signature verification confirms that the request was signed using the endpoint secret and that its body was not modified. It protects the endpoint from forged webhook requests.
Idempotency prevents a valid Stripe event from changing your records or triggering fulfilment more than once.
A secure webhook implementation needs both.
Let’s Build Your Web App Together!
We build fast, scalable, and secure web applications that help your business grow. From idea to launch, we handle it all.
Handling Duplicate Stripe Events
Stripe retries events when it does not receive a successful response. Network timeouts can also cause Stripe to retry even when your server completed the first request.
The unique index on stripe_event_id prevents the same event ID from being inserted twice:
add_index :stripe_webhook_events,
:stripe_event_id,
unique: trueWhen Rails raises ActiveRecord::RecordNotUnique, the controller returns 200 OK, telling Stripe that no further retry is required.
Stripe may occasionally generate separate Event objects for the same underlying resource change. For especially sensitive actions, also make the business operation itself idempotent—for example, by checking fulfilled_at before delivering an order.
Handling Events That Arrive Out of Order
Stripe does not guarantee that events will arrive in the order they were created.
For example, your application could receive:
payment_intent.succeededbefore an older:
payment_intent.payment_failedBlindly processing the failure afterward could incorrectly change a successful payment back to failed.
The example stores event.created in last_stripe_event_created_at and ignores events older than the last processed update.
For more complex payment state machines, retrieve the latest Payment Intent from Stripe before making a critical transition.
What HTTP Status Should the Endpoint Return?
Use the response status to tell Stripe whether it should retry:
| Situation | Recommended response |
| Valid event processed successfully | 200 OK |
| Valid but irrelevant event | 200 OK |
| Event already processed | 200 OK |
| Invalid JSON payload | 400 Bad Request |
| Invalid Stripe signature | 400 Bad Request |
| Temporary database or server failure | 500 Internal Server Error |
Do not return 500 for events you deliberately ignore. Stripe will repeatedly resend them even though another attempt cannot change the result.
Common Stripe Webhook Errors
Webhook signature verification fails
Verify that you pass request.raw_post directly to Stripe::Webhook.construct_event. Do not parse and regenerate the JSON before checking its signature.
The signing secret appears correct but verification still fails
The Stripe CLI and Dashboard endpoints generate different secrets. Use the CLI secret for locally forwarded events and the Dashboard endpoint secret for deployed requests.
The same order is fulfilled twice
Saving the webhook event alone is not enough. Make the fulfilment operation idempotent by checking a state such as fulfilled_at before delivering the order again.
Stripe keeps resending the event
Stripe did not receive a successful response. Check response codes, application timeouts, exceptions, load-balancer logs, and whether slow processing should be moved to a background job.
The Payment Intent event has no matching payment
Make sure the local payment record is saved before the Payment Intent is confirmed. Also verify that test-mode events are not being compared with live-mode records.
Production Checklist
Before enabling live payments:
- Verify every event using the raw request body.
- Store Stripe event IDs with a unique index.
- Subscribe only to required event types.
- Keep test and live signing secrets separate.
- Use HTTPS for the deployed endpoint.
- Make fulfilment and other side effects idempotent.
- Process slow actions through background jobs.
- Log unknown events without treating them as failures.
- Monitor failed webhook deliveries in Stripe.
- Protect logs from unnecessary customer or payment data.
- Test duplicate, delayed, invalid, and out-of-order events.
- Rotate the signing secret if it may have been exposed.
FAQ
What is a Stripe webhook?
A Stripe webhook is an HTTP request sent to your server when a Stripe event occurs. It allows your application to process payment updates without relying on browser redirects.
Which Payment Intent events should a Rails application handle?
Most integrations begin with payment_intent.succeeded and payment_intent.payment_failed. Additional events may be required depending on supported payment methods, refunds, cancellations, disputes, and fulfilment rules.
Why must Stripe webhooks use the raw request body?
Stripe calculates the signature from the exact payload it sends. Parsing, reformatting, or otherwise modifying that body before verification can cause valid signatures to fail.
How do you prevent duplicate Stripe webhook processing?
Store each Stripe event ID in a database column with a unique index. Also make business actions such as order fulfilment idempotent because related events may still occur separately.
Where can you find the Stripe webhook signing secret?
Open the webhook endpoint in Stripe Workbench or the Dashboard and reveal its signing secret. For local forwarding, use the separate secret displayed by stripe listen.
Conclusion
Stripe Payment Intent webhooks provide a reliable way to update payment state independently of the customer’s browser. However, a production endpoint needs more than a controller that accepts JSON.
It should verify the raw payload, reject invalid signatures, prevent duplicate processing, tolerate event retries, account for events arriving out of order, and move slow side effects to idempotent background jobs.
With those safeguards in place, Rails can process Stripe payment events securely without continuously polling the Payment Intents API.



