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:
- Retrieve charge object from event
- Find or create donor by Stripe customer ID
- Create
Donations::Notificationrecord - Create
Donations::Transactionrecord:- Amount and currency
- Transaction ID (charge ID)
- Payment date (charge creation timestamp)
- Associate transaction with subscription (if recurring)
- Associate transaction with campaign (background job)
- Send thank you email to donor
- 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:
- Create notification record
- Track failure count for this payment intent
- Check for fraud indicators:
- More than 3 failures in 30 minutes
- Stripe fraud detection flag
- If fraud suspected:
- Cancel payment intent immediately
- Send high-priority staff alert
- 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:
- Retrieve subscription object from event
- Find donor by customer ID
- Create
Donations::Notificationrecord - Create
Donations::Subscriptionrecord:- Subscription ID (from Stripe)
- Start date
- Recurring flag (true)
- Associate notification with subscription
- 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:
- Retrieve subscription object from event
- Find subscription record by Stripe subscription ID
- Update
ended_attimestamp - Create notification record
- 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:
- Schedule expiration job for 30 minutes later
- 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:
- Webhook configured in Stripe Dashboard?
- Correct endpoint URL?
- Events selected in webhook settings?
- Application server accessible from internet?
- 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:
- Job enqueued? Check Sidekiq queue
- Job failed? Check Sidekiq failures
- Exception raised? Check Rollbar
- 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
- Email Notifications - Email templates and triggers
- Error Handling - Detailed error scenarios
- Admin Features - Reports and management