Most Stripe tutorials show how to accept a payment. They stop before the part that matters: what happens next.

Buyer clicks your payment link. Stripe processes the charge. Now what? If you are the answer to that question, you have a manual fulfillment process. It works at three sales per week. It breaks at thirty. And it means you are the constraint on your own revenue.

This post shows the five-stage pattern we run in production at OperatorIQ. Buyer clicks payment link. Stripe fires webhook. Python verifies, guards, routes. SMTP delivers. Nobody touches anything. The buyer has the product in their inbox within 60 seconds.


Stage 1: Receive and Verify the Stripe Webhook

Stripe signs every webhook payload using a signing secret you configure per endpoint. The first thing your handler does is verify that signature.

The detail most tutorials miss: Stripe signs the raw request bytes, not parsed JSON. If your framework parses the body before your route handler sees it, signature verification fails even on legitimate events.

import stripe
import os
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()  # raw bytes, not request.json
    sig_header = request.headers.get('Stripe-Signature')

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, os.environ['STRIPE_WEBHOOK_SECRET']
        )
    except stripe.error.SignatureVerificationError:
        abort(400)

    if event['type'] == 'checkout.session.completed':
        handle_checkout_completed(event['data']['object'])

    return '', 200

Return 200 immediately after routing. Do not run fulfillment inside the request handler. Stripe expects a response within 30 seconds. A model call plus email delivery can take 10 to 20 seconds. Decouple early.


Stage 2: Extract the Buyer Email and Product Slug

The checkout.session.completed payload contains the buyer email and anything you put in the payment link's metadata. This is where the product_slug pattern earns its place.

When you create a Stripe payment link, set a metadata key: product_slug with a value like llmradar_audit_197. Your handler reads that slug and routes to the right fulfillment function. New products require no changes to the webhook handler itself.

def handle_checkout_completed(session):
    buyer_email = session.get('customer_email')
    product_slug = session.get('metadata', {}).get('product_slug')
    session_id = session['id']

    if not buyer_email or not product_slug:
        log_error(f"Missing fields in session {session_id}")
        return

    fulfill(buyer_email, product_slug, session_id)

One function. Two fields extracted. If either is missing, log and stop. Never fulfill with incomplete data.


Stage 3: Idempotent Fulfillment Guard

Stripe delivers webhooks at least once. During retries (which happen when your endpoint times out or returns non-2xx), the same event can fire two or three times. Without a guard, buyers get duplicate delivery emails.

The cleanest guard is a Supabase table with a unique constraint on stripe_session_id. For more on the idempotency patterns behind this design, that post covers the schema and the edge cases in detail.

from supabase import create_client

supabase = create_client(
    os.environ['SUPABASE_URL'],
    os.environ['SUPABASE_SERVICE_ROLE_KEY']
)

def fulfill(email: str, product_slug: str, session_id: str):
    existing = supabase.table('fulfillments') \
        .select('id') \
        .eq('stripe_session_id', session_id) \
        .execute()

    if existing.data:
        return  # already processed

    supabase.table('fulfillments').insert({
        'stripe_session_id': session_id,
        'buyer_email': email,
        'product_slug': product_slug,
        'status': 'pending'
    }).execute()

    send_delivery_email(email, product_slug)

    supabase.table('fulfillments') \
        .update({'status': 'completed'}) \
        .eq('stripe_session_id', session_id) \
        .execute()

The Supabase table gives you an audit log at no extra cost. Query it to see every fulfillment, check for stuck rows, and replay failed deliveries if needed.


Stage 4: Send the Delivery Email via SMTP

No SendGrid required. Python's smtplib with a dedicated sending domain handles transactional delivery reliably. The three requirements that actually determine deliverability are a dedicated sending domain, an SPF record, and DKIM configuration. Without all three, expect 30-40% spam placement regardless of which email service you use.

import smtplib
import secrets
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

def send_delivery_email(to_email: str, product_slug: str):
    token = secrets.token_urlsafe(32)
    download_url = f"https://operatoriq.io/download/{product_slug}?token={token}"

    msg = MIMEMultipart()
    msg['From'] = os.environ['SMTP_FROM']
    msg['To'] = to_email
    msg['Subject'] = "Your purchase is ready"

    body = f"""Your download is ready.

{download_url}

Link expires in 48 hours. Reply to this email with any questions.
"""
    msg.attach(MIMEText(body, 'plain'))

    with smtplib.SMTP_SSL(
        os.environ['SMTP_HOST'], int(os.environ['SMTP_PORT'])
    ) as server:
        server.login(os.environ['SMTP_USER'], os.environ['SMTP_PASSWORD'])
        server.send_message(msg)

Stage 5: The Full Flow in Production

Wired together, here is what the chain looks like from click to delivery:

  1. Buyer clicks payment link (Stripe link with product_slug metadata set)
  2. Stripe processes payment, fires checkout.session.completed to your endpoint
  3. Flask handler receives POST, extracts raw bytes, verifies Stripe signature
  4. handle_checkout_completed extracts buyer email and product_slug
  5. fulfill checks Supabase for existing row
  6. Supabase row written with status: pending
  7. send_delivery_email delivers download link via SMTP
  8. Supabase row updated to status: completed

Total time from payment to buyer inbox: under 60 seconds in normal conditions. Zero humans.

The only ongoing maintenance: monitor for status = pending rows older than 5 minutes.

SELECT * FROM fulfillments
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '5 minutes';

A row stuck in pending means the SMTP call failed before the status update. That row is your alert. Replay it manually, fix the root cause, and the table documents the incident.

This is the same fulfillment pattern that powers the autonomous fulfillment chain we described in the 14-day build post.


Run It Yourself or Have Us Build It

We run exactly this stack in production for OperatorIQ's product sales. If you want the full implementation including the Supabase schema, download token expiry validation, and monitoring cron, the Concierge service covers it end-to-end at $1,997. We build it, you ship it. No calls required.

Or if you want to start by understanding where your brand stands in AI search before investing in automation, run an LLMRadar AI brand audit. It is a $197 one-time scan that shows you exactly how your product appears across ChatGPT, Perplexity, Claude, and Gemini. Buyers who find you through AI search are already pre-qualified. The audit tells you whether they can find you at all.