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 started
  • customer.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 succeeded
  • invoice.payment_failed — a renewal payment failed (dunning begins)

One-time payment:

  • payment_intent.succeeded — a one-time payment completed
  • checkout.session.completed — a Checkout Session (Stripe’s hosted page) completed

Customer:

  • customer.created — new customer created in Stripe
  • customer.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.