Blogs/Technology

How to Integrate Stripe Payment Intent Webhooks in Rails

Written byMurtuza Kutub
Aug 10, 2026
10 Min Read
How to Integrate Stripe Payment Intent Webhooks in Rails Hero
Too Long? Read This First

- 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
canceled

Some 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_failed

Stripe 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:

  1. Your application creates a Payment Intent.
  2. The local payment record stores the Stripe Payment Intent ID.
  3. The customer attempts the payment.
  4. Stripe updates the Payment Intent.
  5. Stripe sends an event to your webhook endpoint.
  6. Rails verifies the event signature.
  7. The application checks whether the event was already processed.
  8. The local payment record is updated.
  9. 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 Payment model
  • 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 install

If 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
end

Run the migration:

bin/rails db:migrate

If 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:datetime

Update 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
end

Run the migration:

bin/rails db:migrate

The 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"
end

The resulting endpoint is:

POST /stripe/webhooks

A 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
end

This 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: :create

This 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 PaymentFulfillment

Update 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
end

This 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:migrate

Checking 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:edit

Add:

stripe:
  secret_key: sk_test_replace_me
  webhook_secret: whsec_replace_me

Never 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/webhooks

The 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.succeeded

Trigger a failed payment event:

stripe trigger payment_intent.payment_failed

The 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/webhooks

Subscribe only to the events the application handles:

payment_intent.succeeded
payment_intent.payment_failed

Selecting 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: true

When 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.succeeded

before an older:

payment_intent.payment_failed

Blindly 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:

SituationRecommended response
Valid event processed successfully200 OK
Valid but irrelevant event200 OK
Event already processed200 OK
Invalid JSON payload400 Bad Request
Invalid Stripe signature400 Bad Request
Temporary database or server failure500 Internal Server Error
Valid event processed successfully
Recommended response
200 OK
1 of 6

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.

Author-Murtuza Kutub
Murtuza Kutub
LinkedIn

A product development and growth expert, helping founders and startups build and grow their products at lightning speed with a track record of success. Apart from work, I love to Network & Travel.

Share this article

Phone

Next for you

8 Best GraphQL Libraries for Node.js in 2025 Cover

Technology

Aug 4, 202613 min read

8 Best GraphQL Libraries for Node.js in 2025

8 Best GraphQL Libraries for Node.js in 2026 Too Long? Read This First - Choose Apollo Server when you need a mature ecosystem, GraphOS integration, plugins, or Apollo Federation. - Choose GraphQL Yoga for a modern, portable server with Fetch API compatibility and built-in support for subscriptions over Server-Sent Events. - Choose Mercurius when your application already uses Fastify and runtime efficiency is a major priority. - Use GraphQL.js when you need the official JavaScript implementati

9 React Native Animation Libraries and Tools Compared Cover

Technology

Aug 4, 202615 min read

9 React Native Animation Libraries and Tools Compared

Too Long? Read This First - Use React Native Reanimated for gesture-driven, interruptible, and performance-sensitive interface animations. - Use the built-in Animated API for simple fades, transforms, and timed sequences without another dependency. - Pair React Native Gesture Handler with Reanimated for swipes, dragging, pinching, rotation, and other touch-driven experiences. - Use Lottie React Native for non-interactive motion graphics supplied by designers. - Choose React Native Skia for cust

9 Critical Practices for Secure Web Application Development Cover

Technology

Aug 4, 202616 min read

9 Critical Practices for Secure Web Application Development

Too Long? Read This First - Define security requirements and model threats before implementation begins. - Treat authentication, account recovery, and MFA as one complete identity system. - Apply server-side authorization to every protected action and object. - Prevent injection with parameterized APIs, structured validation, safe output handling, and restricted outbound requests. - Protect sessions and tokens throughout their complete lifecycle. - Minimise sensitive data and manage encryption