Stripe webhooks are the primary mechanism for connecting payment events to your marketing infrastructure — CRM updates, email sequences, revenue reporting, and customer lifecycle automation all depend on receiving and processing Stripe events reliably. The implementation looks simple until you encounter the production failure modes: duplicate event processing, missing events during downtime, and handlers that fail silently.
This guide builds a production-ready Stripe webhook integration from the ground up, with specific attention to the MarTech use cases that depend on payment event data.
Understanding Stripe’s Event Model
Stripe delivers webhooks as HTTP POST requests to your configured endpoint. Each webhook contains a single Event object with a type field describing what happened and a data.object field containing the relevant Stripe object.
The events that matter most for MarTech integrations:
Subscription lifecycle:
customer.subscription.created— a new subscription startedcustomer.subscription.updated— subscription changed (plan upgrade, downgrade, pause)customer.subscription.deleted— subscription canceled (not necessarily immediately — at period end)invoice.payment_succeeded— a subscription renewal payment succeededinvoice.payment_failed— a renewal payment failed (dunning begins)
One-time payment:
payment_intent.succeeded— a one-time payment completedcheckout.session.completed— a Checkout Session (Stripe’s hosted page) completed
Customer:
customer.created— new customer created in Stripecustomer.updated— customer data changed (email update, metadata)
For marketing automation, the subscription lifecycle and payment events are the most important. They drive: onboarding email sequences on subscription creation, churn prevention on payment failure, plan-tier-based audience segmentation for ad platforms, and revenue reporting in your data warehouse.
Signature Verification
Stripe signs every webhook with a signature using your endpoint’s webhook signing secret. Verifying this signature is mandatory — without it, your endpoint accepts fabricated payment events from anyone who knows your URL.
from flask import Flask, request, jsonify, abort
import stripe
import json
from datetime import datetime
app = Flask(__name__)
STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your endpoint's signing secret
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data(as_text=True)
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError:
# Invalid payload
abort(400)
except stripe.error.SignatureVerificationError:
# Invalid signature
abort(400)
# Process the verified event
handle_stripe_event(event)
return jsonify({'status': 'received'}), 200
Stripe’s construct_event method verifies the signature and also validates the webhook timestamp — events older than the tolerance window (default 300 seconds) are rejected, protecting against replay attacks.
Idempotent Event Processing
Stripe delivers each event at least once — under failure conditions, it may deliver the same event multiple times. Your handler must be idempotent: processing the same event twice must produce the same result as processing it once.
import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def is_event_processed(event_id):
"""Check and mark an event as processed atomically."""
key = f"stripe:processed:{event_id}"
# SETNX: set only if not exists. Returns 1 if set, 0 if already existed.
result = redis_client.set(key, '1', ex=86400 * 7, nx=True)
return not result # True if already processed
def handle_stripe_event(event):
event_id = event['id']
if is_event_processed(event_id):
# Already processed — skip silently
return
event_type = event['type']
data = event['data']['object']
handlers = {
'customer.subscription.created': handle_subscription_created,
'customer.subscription.updated': handle_subscription_updated,
'customer.subscription.deleted': handle_subscription_canceled,
'invoice.payment_succeeded': handle_payment_succeeded,
'invoice.payment_failed': handle_payment_failed,
'checkout.session.completed': handle_checkout_completed,
}
handler = handlers.get(event_type)
if handler:
handler(data)
Connecting Stripe Events to Your CRM
The most common MarTech use case: syncing subscription state to a CRM (HubSpot, Salesforce) so that sales, support, and marketing have current customer information.
def handle_subscription_created(subscription):
"""Create or update a CRM contact when a subscription starts."""
customer = stripe.Customer.retrieve(subscription['customer'])
# Get or create the CRM contact by email
crm_contact = crm_client.upsert_contact({
'email': customer['email'],
'properties': {
'stripe_customer_id': customer['id'],
'stripe_subscription_id': subscription['id'],
'subscription_status': subscription['status'],
'subscription_plan': subscription['items']['data'][0]['price']['nickname'],
'subscription_start_date': datetime.fromtimestamp(
subscription['start_date']
).isoformat(),
'monthly_recurring_revenue': calculate_mrr(subscription),
'lifecycle_stage': 'customer'
}
})
# Trigger onboarding automation sequence
crm_client.enroll_in_workflow(
contact_id=crm_contact['id'],
workflow_name='new_customer_onboarding'
)
# Send to data warehouse
warehouse.insert('subscriptions', {
'stripe_subscription_id': subscription['id'],
'customer_email': customer['email'],
'plan': subscription['items']['data'][0]['price']['id'],
'mrr': calculate_mrr(subscription),
'started_at': datetime.fromtimestamp(subscription['start_date'])
})
def calculate_mrr(subscription):
"""Calculate monthly recurring revenue from a subscription."""
total_amount = 0
for item in subscription['items']['data']:
price = item['price']
quantity = item.get('quantity', 1)
unit_amount = price['unit_amount'] # In cents
# Normalize to monthly
if price['recurring']['interval'] == 'year':
unit_amount = unit_amount / 12
elif price['recurring']['interval'] == 'week':
unit_amount = unit_amount * 4.33
total_amount += unit_amount * quantity
return total_amount / 100 # Convert cents to dollars
def handle_payment_failed(invoice):
"""Handle subscription payment failure — trigger dunning."""
customer_id = invoice['customer']
customer = stripe.Customer.retrieve(customer_id)
attempt_count = invoice['attempt_count']
# Update CRM with payment failure state
crm_client.update_contact_by_email(customer['email'], {
'subscription_status': 'past_due',
'payment_failure_count': attempt_count,
'last_payment_failure_date': datetime.now().isoformat()
})
# Trigger appropriate dunning email based on attempt count
dunning_workflows = {
1: 'payment_failed_first_attempt',
2: 'payment_failed_second_attempt',
3: 'payment_failed_final_warning',
}
workflow = dunning_workflows.get(attempt_count)
if workflow:
crm_client.trigger_email_workflow(customer['email'], workflow)
Handling Subscription Cancellations Correctly
Stripe’s subscription cancellation model has an important nuance: by default, canceling a subscription cancels it at the end of the current billing period, not immediately. The subscription status is canceled only after the period ends.
def handle_subscription_canceled(subscription):
"""Handle subscription cancellation — immediate or end-of-period."""
canceled_at = subscription.get('canceled_at')
current_period_end = subscription['current_period_end']
if canceled_at and canceled_at <= current_period_end:
# Canceled immediately — access ends now
handle_immediate_cancellation(subscription)
else:
# Scheduled cancellation — still active until period end
handle_scheduled_cancellation(subscription)
def handle_scheduled_cancellation(subscription):
"""Customer canceled but still has active access."""
customer = stripe.Customer.retrieve(subscription['customer'])
crm_client.update_contact_by_email(customer['email'], {
'subscription_status': 'cancel_scheduled',
'subscription_end_date': datetime.fromtimestamp(
subscription['current_period_end']
).isoformat(),
'churn_reason': subscription.get('cancellation_details', {}).get('reason')
})
# Trigger win-back campaign based on time until expiry
days_remaining = (
datetime.fromtimestamp(subscription['current_period_end']) -
datetime.now()
).days
if days_remaining > 7:
crm_client.trigger_email_workflow(customer['email'], 'cancellation_winback')
Handling Missing Events: The Reconciliation Pattern
Webhook delivery is reliable but not perfectly reliable — your endpoint may be unavailable during maintenance windows, and Stripe retries failed deliveries for 72 hours. Events can be missed if your endpoint is down for longer.
The production pattern is periodic reconciliation against Stripe’s list API:
def reconcile_subscriptions(lookback_hours=24):
"""Reconcile local subscription state against Stripe."""
since = int((datetime.now() - timedelta(hours=lookback_hours)).timestamp())
# List all subscriptions modified since lookback window
subscriptions = stripe.Subscription.list(
created={'gte': since},
limit=100,
expand=['data.customer']
)
for subscription in subscriptions.auto_paging_iter():
local_state = db.get_subscription(subscription['id'])
if not local_state:
# Missing — process as new
handle_subscription_created(subscription)
elif local_state['status'] != subscription['status']:
# Status mismatch — update
handle_subscription_updated(subscription)
Run this reconciliation job daily to catch any events missed during downtime windows.
Frequently Asked Questions
How do we test Stripe webhooks locally without a public URL?
The Stripe CLI provides a stripe listen --forward-to localhost:5000/webhooks/stripe command that creates a tunnel from Stripe’s servers to your local development environment. It also prints the test webhook signing secret to use in place of your production secret. Install the Stripe CLI and use it for all local webhook development — it is the official Stripe-recommended local development approach.
Should we verify subscription status directly in Stripe or trust our local database?
For displaying subscription status to users (in account settings, feature gating), always verify directly with Stripe in real-time, or maintain a local subscription table that is continuously kept in sync via webhooks. Do not rely on the CRM as the source of truth for subscription state — the CRM receives this data from your webhook handler, so it is one step removed and can be stale.
How do we handle Stripe webhook endpoint failures (our server returns 5xx)?
Stripe retries failed deliveries with exponential backoff for up to 72 hours. After 72 hours without a successful acknowledgment, the event is marked as failed and will not be retried. For extended outages, use the Stripe Dashboard or API to identify failed events and manually trigger them using the Stripe CLI (stripe events resend <event_id>). Implement the reconciliation pattern described above as a permanent backstop.
What Stripe data should we store in our own database vs. call Stripe’s API for each time?
Store subscription ID, status, current period end date, plan name/ID, and customer email in your database — you query these frequently and do not need real-time Stripe accuracy for most uses. For payment method details, invoice amounts, and complete payment history, call Stripe’s API rather than duplicating — payment data requires PCI compliance considerations if stored.
How do we connect Stripe events to Google Analytics 4 for revenue tracking?
Use GA4’s Measurement Protocol to send purchase events from your webhook handler when payment_intent.succeeded or invoice.payment_succeeded fires. Include the revenue amount, currency, and transaction ID. This ensures purchase events are tracked server-side without depending on client-side tag firing, which is unreliable for post-checkout page loads. See the server-side tracking guide for the Measurement Protocol implementation pattern.
Further Reading from Authoritative Sources
- MDN Web Docs — HTTP POST method: Reference for the HTTP POST method used in webhook delivery, including request body handling and response code semantics relevant to webhook endpoint implementation.
- OWASP — Cross-Site Request Forgery Prevention: CSRF considerations for webhook endpoints — understanding why CSRF protection is typically disabled for webhook endpoints and how signature verification replaces it.


