Skip to main content

Stripe Webhook Integration

This document explains how the application processes Stripe webhook events for payments, subscriptions, and other events.

Webhook Endpoint Configuration

Endpoint URL

POST /donations/event

This endpoint receives all webhook events from Stripe.

Stripe Webhook Settings

In the Stripe Dashboard, configure the webhook with:

  • URL: https://donations.aleteia.org/event (production)
  • Events to send: Select specific events (see below)
  • API version: Use account's default API version

Security Configuration

The webhook endpoint is protected by two security layers:

1. HTTP Basic Authentication

# app/controllers/donations/stripe_controller.rb
STRIPE_AUTHENTICATION_SECRET_V2 = Rails.application.secrets.stripe_webhook_secret!

before_action :authenticate_webhook_request, only: :event

def authenticate_webhook_request
authenticate_or_request_with_http_basic do |_, password|
ActiveSupport::SecurityUtils.secure_compare(password, STRIPE_AUTHENTICATION_SECRET_V2)
end
end

Username can be any value (typically "stripe"), password must match the secret.

2. Stripe Signature Verification

# app/controllers/donations/stripe_controller.rb
STRIPE_SIGNING_SECRET_V2 = Rails.application.secrets.stripe_signing_secret!

def event
# Parse and verify signature
event = Stripe::Webhook.construct_event(
request.body.read,
request.headers['Stripe-Signature'],
STRIPE_SIGNING_SECRET_V2
)

# Enqueue background job for processing
Donations::ProcessStripeEventJob.perform_later(event.id)

head :ok # Acknowledge receipt immediately
rescue Stripe::SignatureVerificationError => e
logger.warn "Failed to verify stripe webhook: #{e.message}"
Rollbar.warning('Failed to verify stripe webhook', e)
head :bad_request
end

Why both layers?

  • HTTP Basic Auth: Fast rejection of non-Stripe requests
  • Signature Verification: Cryptographic proof the event came from Stripe
  • Defense in depth: If one fails, the other protects

Webhook Events Processed

The application handles the following Stripe events:

Payment Events

charge.succeeded

When: A charge is successfully completed.

Triggered by:

  • Single donation payment confirmed
  • Recurring donation monthly charge processed

Processing:

  1. Retrieve charge object from event
  2. Find or create donor by Stripe customer ID
  3. Create Donations::Notification record
  4. Create Donations::Transaction record:
    • Amount and currency
    • Transaction ID (charge ID)
    • Payment date (charge creation timestamp)
  5. Associate transaction with subscription (if recurring)
  6. Associate transaction with campaign (background job)
  7. Send thank you email to donor
  8. Send Slack notification (if configured)

Code:

# app/jobs/donations/process_stripe_event_job.rb
when Donations::Notification::CHARGE_CREATED
process_charge

def process_charge
charge = event.data.object
raise NeedInvestigationError unless charge['status'] == 'succeeded'

notification = save_notification!(charge.customer)

# Associate with subscription if recurring
if notification.recurring?
subscription = Donations::Subscription.find_by!(
subscr_id: notification.stripe_subscription_id
)
notification.update!(subscription: subscription)
end

# Create transaction
txn = notification.create_saved_transaction(
amount: Money.new(charge.amount, charge.currency),
transaction_id: charge.id,
payment_date: Time.at(charge.created)
)

# Associate with campaign asynchronously
Donations::AssociateTransactionWithCampaignJob.perform_later(event.id, txn)
end

Distinguishing Recurring vs One-Time:

# Recurring charges have an invoice attached
def recurring?
(txn_type == SUBSCR_PAYMENT && subscription.recurring?) ||
(stripe_charge? && stripe_object.invoice.present?)
end

charge.failed

When: A charge attempt fails.

Triggered by:

  • Card declined
  • Insufficient funds
  • Fraud detection
  • Network error

Processing:

  1. Create notification record
  2. Track failure count for this payment intent
  3. Check for fraud indicators:
    • More than 3 failures in 30 minutes
    • Stripe fraud detection flag
  4. If fraud suspected:
    • Cancel payment intent immediately
    • Send high-priority staff alert
  5. Otherwise:
    • Send failure email to donor
    • Send standard staff notification

Code:

# app/jobs/donations/process_stripe_event_job.rb
when Donations::Notification::CHARGE_FAILED
process_failed_charge

def process_failed_charge
charge = event.data.object

# Track failure
Stripe.limits.add(charge_failure_id(charge))

# Save notification
save_notification!(charge.customer)

if charge.fraud_attempt? || too_much_failures?(charge)
logger.warn "Possible fraud attempt on charge #{charge.id}"

# Send alert
Donations::DonorMailer.staff_charge_failed_notification(
event.id,
prefix: '[ALERT] Possible fraud attempt'
)

# Cancel payment intent to prevent further attempts
Donations::ExpirePaymentIntentJob.perform_later(
charge.payment_intent,
reason: 'fraudulent'
)
else
# Normal failure - inform donor and staff
Donations::DonorMailer.charge_failed(event.to_hash).deliver_later
Donations::DonorMailer.staff_charge_failed_notification(event.id)
end
end

def too_much_failures?(charge)
Stripe.limits.exceeded?(
charge_failure_id(charge),
threshold: 3,
interval: 30.minutes
)
end

Subscription Events

customer.subscription.created

When: A new subscription is created.

Triggered by:

  • User completes recurring donation setup
  • Subscription created via API

Processing:

  1. Retrieve subscription object from event
  2. Find donor by customer ID
  3. Create Donations::Notification record
  4. Create Donations::Subscription record:
    • Subscription ID (from Stripe)
    • Start date
    • Recurring flag (true)
  5. Associate notification with subscription
  6. Send Slack notification

Code:

# app/jobs/donations/process_stripe_event_job.rb
when Donations::Notification::SUBSCRIPTION_CREATED
create_subscription

def create_subscription
subscription = event.data.object

Donations::Notification.transaction do
notification = save_notification!(subscription.customer)

sub = Donations::Subscription.create!(
subscr_id: subscription.id,
recurring: true,
started_at: Time.at(subscription.start)
)

notification.update!(subscription: sub)
end
end

customer.subscription.deleted

When: A subscription is canceled or expires.

Triggered by:

  • User cancels recurring donation
  • Subscription canceled via API
  • Subscription expires after multiple failed payments

Processing:

  1. Retrieve subscription object from event
  2. Find subscription record by Stripe subscription ID
  3. Update ended_at timestamp
  4. Create notification record
  5. Send cancellation email to donor

Code:

# app/jobs/donations/process_stripe_event_job.rb
when Donations::Notification::SUBSCRIPTION_DELETED
close_subscription

def close_subscription
subscription = event.data.object

Donations::Notification.transaction do
notification = save_notification!(subscription.customer)

sub = Donations::Subscription.find_by!(subscr_id: subscription.id)
sub.update!(ended_at: Time.at(subscription.canceled_at))

notification.update!(subscription: sub)
end
end

Intent Events

payment_intent.created

When: A new payment intent is created.

Triggered by:

  • User initiates payment flow
  • Payment intent created via API

Processing:

  1. Schedule expiration job for 30 minutes later
  2. If not completed by then, payment intent will be canceled

Code:

# app/jobs/donations/process_stripe_event_job.rb
INTENT_CREATED = 'payment_intent.created'.freeze

when INTENT_CREATED
Donations::ExpirePaymentIntentJob
.set(wait: 30.minutes)
.perform_later(event.data.object.id)

This prevents abandoned payment intents from being reused indefinitely.

Event Processing Flow

1. Webhook Receipt (Controller)

# app/controllers/donations/stripe_controller.rb
def event
# Verify signature and parse event
event = Stripe::Webhook.construct_event(
request.body.read,
request.headers['Stripe-Signature'],
STRIPE_SIGNING_SECRET_V2
)

# Enqueue background job (async processing)
Donations::ProcessStripeEventJob.perform_later(event.id)

# Respond immediately to Stripe
head :ok
end

Why respond immediately?

  • Stripe expects response within seconds
  • Processing can take longer (database, email, etc.)
  • Background job handles actual work
  • Prevents timeout issues

2. Background Processing (Job)

# app/jobs/donations/process_stripe_event_job.rb
def perform(event_id)
# Retrieve fresh event from Stripe API
@event = Stripe::Event.retrieve(event_id)

logger.info "Processing stripe event #{event_id} of type #{event.type}"

# Track metrics
Stripe.limits.add(event.type)

# Route to appropriate handler
case event.type
when INTENT_CREATED
schedule_expiration
when CHARGE_CREATED
process_charge
when SUBSCRIPTION_CREATED
create_subscription
when SUBSCRIPTION_DELETED
close_subscription
when CHARGE_FAILED
process_failed_charge
else
logger.info "Skipping ignored event #{event.type}"
end
end

Event Retrieval:

  • Event ID passed to job (not full event object)
  • Job retrieves fresh data from Stripe API
  • Ensures data is up-to-date
  • Handles edge cases (event updated since webhook)

3. Notification Storage

All events are logged in the database:

# app/jobs/donations/process_stripe_event_job.rb
def save_notification!(customer_id)
donor = find_donor!(customer_id)

donor.notifications.create!(
txn_type: event.type,
data: event.to_h
)
end

Benefits:

  • Audit trail of all events
  • Can replay events if needed
  • Historical record for debugging
  • Source for reports and analytics

Error Handling & Retries

Webhook Retry Logic (Stripe's Side)

Stripe automatically retries failed webhooks:

  • First retry: Immediately
  • Subsequent retries: Exponential backoff
  • Max retries: 72 hours
  • Stops after first successful response (2xx status)

What constitutes failure?

  • Non-2xx HTTP status code
  • Connection timeout
  • Network error

Application Error Handling

Transient Errors:

# If job fails, Sidekiq will retry
class Donations::ProcessStripeEventJob < ApplicationJob
queue_as :payments

# Sidekiq default: 25 retries with exponential backoff

def perform(event_id)
# If exception raised, job will retry
end
end

Permanent Errors:

# Some errors should not retry
rescue Stripe::InvalidRequestError => e
# Event doesn't exist or invalid
logger.error "Invalid stripe event: #{e.message}"
Rollbar.error(e)
# Don't raise - job completes without retry
end

Need Investigation:

# Custom exception for anomalies
NeedInvestigationError = Class.new(StandardError)

# Raise for data inconsistencies
def process_charge
charge = event.data.object

raise NeedInvestigationError, "Charge status is #{charge.status}" unless charge.status == 'succeeded'

# ... process charge
end

This exception is caught by error tracking (Rollbar) and alerts staff.

Webhook Testing

Testing Locally with Stripe CLI

Install Stripe CLI:

brew install stripe/stripe-cli/stripe

Forward webhooks to local server:

stripe listen --forward-to localhost:3000/donations/event

Trigger test events:

# Successful charge
stripe trigger charge.succeeded

# Failed charge
stripe trigger charge.failed

# New subscription
stripe trigger customer.subscription.created

# Canceled subscription
stripe trigger customer.subscription.deleted

Testing in Test Suite

Mock Webhook Event:

# spec/factories/stripe_events.rb
FactoryBot.define do
factory :stripe_event do
transient do
event_type { 'charge.succeeded' }
event_data { {} }
end

initialize_with do
Stripe::Event.construct_from(
id: "evt_#{SecureRandom.hex(12)}",
type: event_type,
data: { object: event_data },
created: Time.now.to_i
)
end
end
end

Webhook Signature:

# spec/factories/stripe_webhooks.rb
FactoryBot.define do
factory :stripe_webhook do
transient do
timestamp { Time.now.to_i }
secret { Rails.application.secrets.stripe_signing_secret! }
end

initialize_with do
# Generate valid signature
payload = attributes[:body]
signature_header = Stripe::Webhook::Signature.generate_header(
payload,
secret,
timestamp: timestamp
)

# Return webhook with signature
OpenStruct.new(
body: payload,
signature: signature_header
)
end
end
end

Test Example:

RSpec.describe Donations::StripeController do
describe 'POST /event' do
let(:event) { build(:stripe_webhook, event_type: 'charge.succeeded') }
let(:headers) {
{
'HTTP_STRIPE_SIGNATURE' => event.signature,
'HTTP_AUTHORIZATION' => http_basic_auth
}
}

it 'enqueues processing job' do
expect {
post :event, body: event.body, headers: headers
}.to have_enqueued_job(Donations::ProcessStripeEventJob)

expect(response).to have_http_status(:ok)
end
end
end

Webhook Event Data Structure

charge.succeeded Event

{
"id": "evt_1234567890",
"object": "event",
"type": "charge.succeeded",
"created": 1640995200,
"data": {
"object": {
"id": "ch_1234567890",
"object": "charge",
"amount": 3000,
"currency": "eur",
"customer": "cus_ABCDEF123456",
"status": "succeeded",
"payment_method": "pm_1234567890",
"invoice": null,
"metadata": {
"locale": "it",
"campaign": "Spring2018",
"campaign_id": "123"
},
"created": 1640995200,
"fraud_details": {},
"outcome": {
"type": "authorized",
"risk_level": "normal"
}
}
}
}

charge.failed Event

{
"id": "evt_9876543210",
"object": "event",
"type": "charge.failed",
"created": 1640995200,
"data": {
"object": {
"id": "ch_9876543210",
"object": "charge",
"amount": 3000,
"currency": "eur",
"customer": "cus_ABCDEF123456",
"status": "failed",
"payment_intent": "pi_1234567890",
"failure_code": "card_declined",
"failure_message": "Your card was declined.",
"metadata": {
"locale": "it",
"campaign": "Spring2018"
}
}
}
}

customer.subscription.created Event

{
"id": "evt_5555555555",
"object": "event",
"type": "customer.subscription.created",
"created": 1640995200,
"data": {
"object": {
"id": "sub_1234567890",
"object": "subscription",
"customer": "cus_ABCDEF123456",
"status": "active",
"plan": {
"id": "3000-EUR-month",
"amount": 3000,
"currency": "eur",
"interval": "month"
},
"default_payment_method": "pm_1234567890",
"current_period_start": 1640995200,
"current_period_end": 1643673600,
"start": 1640995200,
"metadata": {
"locale": "it",
"campaign": "Spring2018",
"campaign_id": "123"
}
}
}
}

Monitoring & Observability

Webhook Delivery Monitoring

Check webhook delivery status in Stripe Dashboard:

  • Attempts: Number of delivery attempts
  • Status: Success (2xx) or Failed
  • Response: HTTP status and body
  • Retry: Automatic retry schedule

Application Monitoring

Logging:

# All webhook events logged
logger.info "Processing stripe event #{event_id} of type #{event.type}: #{event.data.object.object} #{event.data.object.id}"

# Successful processing
logger.info "Recorded transaction #{txn.id} for charge #{txn.transaction_id}"

# Failures
logger.warn "Received possible fraud attempt on charge #{charge.id}"

Metrics:

# Track event types
Stripe.limits.add(event.type)

# Track failures per payment intent
Stripe.limits.add("charge.failed::#{payment_intent_id}")

# Check if threshold exceeded
Stripe.limits.exceeded?(key, threshold: 3, interval: 30.minutes)

Error Tracking:

# All exceptions sent to Rollbar
rescue StandardError => e
Rollbar.error('Stripe webhook processing failed', e, event_id: event.id)
raise # Re-raise for Sidekiq retry
end

Alerts & Notifications

Slack Notifications:

# app/models/donations/notification.rb
after_commit(on: :create, if: :slack_enabled?) do
Donations::SlackNotificationJob.perform_later(self)
end

def slack_enabled?
URI::DEFAULT_PARSER.make_regexp(['https']).match?(
Setting.donations_slack_webhook_url
)
end

Email Alerts:

# Charge failed
Donations::DonorMailer.charge_failed(event.to_hash).deliver_later

# Staff notification
Donations::DonorMailer.staff_charge_failed_notification(event.id)

# High-priority fraud alert
Donations::DonorMailer.staff_charge_failed_notification(
event.id,
prefix: '[ALERT] Possible fraud attempt'
)

Troubleshooting

Webhook Not Received

Check:

  1. Webhook configured in Stripe Dashboard?
  2. Correct endpoint URL?
  3. Events selected in webhook settings?
  4. Application server accessible from internet?
  5. Firewall/network blocking Stripe IPs?

Debug:

# Check webhook logs in Stripe Dashboard
# Attempt manual resend from dashboard
# Check application logs for incoming requests

Signature Verification Failed

Causes:

  • Wrong signing secret
  • Request body modified (middleware, proxy)
  • Timestamp too old (>5 minutes)

Debug:

# Log signature details
logger.debug "Stripe signature: #{request.headers['Stripe-Signature']}"
logger.debug "Request body: #{request.body.read}"

# Verify secret configured correctly
logger.debug "Signing secret: #{STRIPE_SIGNING_SECRET_V2[0..10]}..."

Event Processing Failed

Check:

  1. Job enqueued? Check Sidekiq queue
  2. Job failed? Check Sidekiq failures
  3. Exception raised? Check Rollbar
  4. Database constraints? Check logs

Recovery:

# Replay event manually
event = Stripe::Event.retrieve('evt_1234567890')
Donations::ProcessStripeEventJob.perform_now(event.id)

Best Practices

1. Idempotent Processing

Ensure events can be processed multiple times safely:

# Use database unique constraints
validates :transaction_id, presence: true, uniqueness: true

# Check if already processed
return if Donations::Transaction.exists?(transaction_id: charge.id)

2. Verify Event Authenticity

Always verify signature - don't trust webhook data blindly.

3. Respond Quickly

Return 2xx status immediately, process asynchronously.

4. Handle All Event Types

Even ignored events should be logged (not raise errors).

5. Monitor Webhook Health

Set up alerts for:

  • Repeated webhook failures
  • Increased processing time
  • High error rate

6. Test Thoroughly

Test all event types, edge cases, and failure scenarios.

Next Steps