Free sample chapter

Chapter 9: Stripe integration guide

From the Payments Playbook by Sean Nieuwoudt and Beverley Merriman. Read the whole chapter here, no email required.

Chapter Overview: Stripe charges 2.9% + $0.30 per card transaction, more than most competitors, and it's still the default for new SaaS integrations. This chapter shows you why, then builds the integration that survives production: Payment Intents, webhooks that arrive twice and out of order, subscriptions you can test without waiting a month, Radar rules and Connect, and what the bill really comes to once the add-ons land.

Learning Objectives

  • Ship a Payment Intents flow that handles 3D Secure instead of failing European checkouts
  • Build a webhook handler that verifies every signature before it fulfills anything
  • Run subscriptions with prorated upgrades and a dunning flow for failed cards
  • Pick between Payment Links, Checkout, and the Payment Element, and turn on Tax and Radar knowing what each one costs
  • Save a card with a Setup Intent and charge it later without the customer present
  • Accept iDEAL, SEPA, and Alipay alongside cards, and split payments with sellers through Connect

Most teams pick Stripe because the docs save them a week and the API covers Connect, subscriptions, and webhooks without needing a second processor.

Production Examples: All code samples from this chapter are in src/examples/9-stripe/ - backend handlers, frontend integration, webhook processing, subscription management, and security implementations.

Why Choose Stripe?

The pitch: a single API across 40+ countries, built-in Radar fraud ML, transparent flat-rate pricing, and a developer experience competitors still chase. The trade-off: 2.9% + $0.30 for cards is higher than Adyen or direct-to-acquirer setups, you build the integration yourself (not plug-and-play), and payouts take 2-7 days depending on the country.

Best for: SaaS applications, e-commerce platforms, marketplaces, subscription businesses, and international businesses.

What Stripe Actually Costs

The 2.9% + $0.30 headline is the domestic online card rate on a US account, and almost nothing you build stays inside it. The table is Stripe's published pricing as of September 2026. The point is the shape of the bill, so check the pricing page before you model margins on it.

ItemCostNotes
Cards, US online2.9% + $0.30The headline
International cards+1.5%Card issued outside your account's country
Currency conversion+1%Charge currency differs from your settlement currency
Stripe Billing+0.7% of subscription volumeSubscriptions, dunning, proration, test clocks
Stripe Invoicing+0.4-0.5% per paid invoiceIf you only send invoices
Stripe Tax+0.5% per transactionOnly in jurisdictions where you're registered to collect
RadarIncludedRadar for Fraud Teams (rules, review queue) adds $0.02 per screened transaction on standard pricing
Connect, Express or Custom accounts$2 per active account per month, plus 0.25% + $0.25 per payoutStandard accounts cost the platform nothing
Disputes$15 eachNot refunded when you win
Checkout, Payment Element, Payment Links, LinkNo extra fee

Run a $50 monthly subscription from a German customer through a US account with Billing on: 2.9% + 1.5% + 1% + 0.7% is 6.1%, plus $0.30, which is $3.35 on $50, or 6.7%. That's the number that belongs in your unit economics, not 2.9%. Chapter 29 covers what you can do about it.

Getting Started

Account Setup

  1. Sign up at stripe.com
  2. Verify your business (required for live payments)
  3. Get your API keys from the Dashboard
  4. Set up webhooks for production reliability

API Keys

Stripe uses publishable and secret keys:

// Publishable key (safe for frontend)
const publishableKey = 'pk_test_51ABC...';

// Secret key (NEVER expose in frontend)
const secretKey = 'sk_test_51ABC...';

Store secret keys in environment variables, use different keys for test and production, rotate them regularly, and never commit them to version control.

Installing SDKs

npm install stripe      # Node.js
pip install stripe      # Python
gem install stripe      # Ruby
composer require stripe/stripe-php  # PHP

Payment Intents

Payment Intents are the only API you should be using for new card flows. They handle the full lifecycle, including 3D Secure authentication, automatically.

Complete Implementation: See src/examples/9-stripe/basic_payment_intent.py

import stripe

def create_payment_intent(amount, currency='usd', customer_id=None):
    """Create a payment intent for one-time payment"""
    intent = stripe.PaymentIntent.create(
        amount=amount,  # Amount in cents
        currency=currency,
        customer=customer_id,
        metadata={'order_id': 'order_12345'},
        automatic_payment_methods=
    )
    return

Frontend Integration

Stripe.js collects the card details in the browser, so they never touch your servers and your PCI scope shrinks with them.

Complete Implementation: See src/examples/9-stripe/frontend_payment.html

const stripe = Stripe('pk_test_YOUR_KEY');
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');

// On form submit
const {error} = await stripe.confirmPayment(
});

3D Secure Handling

3D Secure adds authentication for card payments (required in Europe under PSD2). Handle the requires_action state:

if intent.status == 'requires_action':
    # Customer needs to authenticate - redirect to 3DS flow
    return
elif intent.status == 'succeeded':
    fulfill_order(intent.metadata['order_id'])

Webhooks

Webhooks are not optional in production. Never fulfill an order from a frontend callback alone.

Complete Implementation: See src/examples/9-stripe/webhook_handler.py

Essential Webhook Events

EventPurpose
payment_intent.succeededPayment completed - fulfill order
payment_intent.payment_failedPayment failed - notify customer
invoice.payment_succeededSubscription payment received
invoice.payment_failedSubscription payment failed
customer.subscription.deletedSubscription canceled
charge.dispute.createdChargeback initiated

Delivery Semantics

Three properties of Stripe's delivery decide how you write your handler.

At least once, never exactly once. Stripe retries any delivery that didn't get a 2xx: in live mode for up to three days with exponential backoff, in test mode only three times over a few hours. A handler that times out gets the same event again, so duplicates show up in normal operation, not just during outages. Deduplicate on event.id, not on the payment intent ID, because one payment intent produces several events.

No ordering guarantee. customer.subscription.deleted can arrive before customer.subscription.created, and invoice.paid before invoice.created. Don't build a state machine that depends on the sequence of webhooks. Treat each event as a hint that something changed, fetch the object from the API, and act on its current state. The API is always current; the webhook payload is a snapshot from the moment the event fired.

Respond fast, work later. Stripe wants a 2xx within seconds. Verify the signature, store the event, return 200, and process from a queue. If your endpoint keeps failing for days, Stripe disables it and emails the account owner, which is a bad way to find out that fulfillment stopped.

Keep test and live endpoints separate. Each has its own signing secret (whsec_...), and the Stripe CLI's listen command hands you a temporary one for local development.

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    event = stripe.Webhook.construct_event(
        request.get_data(), request.headers.get('Stripe-Signature'),
        os.environ['STRIPE_WEBHOOK_SECRET']
    )
    if not event_store.claim(event['id']):   # first delivery wins; the rest are duplicates
        return 'Already processed', 200
    queue.enqueue(process_stripe_event, event['id'], event['type'])
    return 'OK', 200

def process_stripe_event(event_id, event_type):
    if event_type.startswith('customer.subscription.'):
        snapshot = stripe.Event.retrieve(event_id)['data']['object']
        subscription = stripe.Subscription.retrieve(snapshot['id'])  # current state, not the snapshot
        sync_subscription(subscription)

Webhook Handler Pattern

from flask import Flask, request
import stripe

@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()
    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:
        return 'Invalid signature', 400

    # Handle event with idempotency
    if event['type'] == 'payment_intent.succeeded':
        payment_intent = event['data']['object']
        if not already_processed(event['id']):
            fulfill_order(payment_intent['metadata']['order_id'])
            mark_processed(event['id'])

    return 'OK', 200

Subscriptions

Stripe handles recurring billing automatically. You create products and prices, then subscribe customers.

Complete Implementation: See src/examples/9-stripe/subscription_management.py

Creating Products and Prices

# One-time setup
product = stripe.Product.create(name="Pro Plan")
price = stripe.Price.create(
    product=product.id,
    unit_amount=2999,  # $29.99
    currency='usd',
    recurring=
)

# Subscribe a customer
subscription = stripe.Subscription.create(
    customer=customer_id,
    items=[{'price': price.id}],
    payment_behavior='default_incomplete',
    expand=['latest_invoice.payment_intent']
)

Handling Upgrades/Downgrades

# Prorate upgrade immediately
stripe.Subscription.modify(
    subscription_id,
    items=[{'id': item_id, 'price': new_price_id}],
    proration_behavior='create_prorations'
)

Dunning Management

Configure retry logic in Stripe Dashboard under Billing > Subscription settings. Handle failed payments via webhooks:

if event['type'] == 'invoice.payment_failed':
    invoice = event['data']['object']
    if invoice['attempt_count'] >= 4:
        suspend_customer_access(invoice['customer'])
        send_final_payment_warning(invoice['customer'])

Proration, Smart Retries, and Test Clocks

Proration. proration_behavior has three settings, and the wrong one generates support tickets. create_prorations (the default) adds credit and debit lines to the next invoice. always_invoice bills the difference immediately, which is what customers expect on an upgrade. none swaps the price and charges nothing until the next cycle, which is right for a downgrade if you'd rather not issue credits. Chapter 7 has the arithmetic; Stripe does it for you, but you still decide when the money moves.

Smart Retries. Stripe's default dunning is Smart Retries: it picks retry times from what has worked for similar cards, inside the window you set in Billing settings, and fires invoice.payment_failed on every attempt. Pair it with Stripe's hosted card-update emails so the customer can fix the card without opening a support ticket, and only suspend access after the last retry, not the first failure.

Test clocks. The part of subscriptions you can't test by waiting. A test clock is a frozen timestamp you attach to a test customer; advance it and every trial end, renewal, and retry that would have happened fires, with real webhooks.

clock = stripe.test_helpers.TestClock.create(frozen_time=int(time.time()))
customer = stripe.Customer.create(test_clock=clock.id, email='trial@example.com')
# attach a payment method, then create a subscription with a 14-day trial ...
stripe.test_helpers.TestClock.advance(clock.id, frozen_time=int(time.time()) + 15 * 86400)
# The trial ends, the first invoice is created and paid, and your webhook endpoint hears about all of it

Run three scenarios on a clock before launch: a trial that converts, a renewal on a card that declines and recovers on retry, and a renewal on a card that never recovers. That's the whole dunning path, and it takes an afternoon instead of two months.

Picking an Integration Surface

Stripe gives you five ways to put a payment form in front of a customer, one of which you shouldn't use, and the choice sets your PCI scope, your conversion rate, and how much frontend code you own.

SurfaceYou writePayment methodsPCIUse it when
Payment LinksNothingEverything enabled in the DashboardSAQ-ASelling before the product exists, invoices, one-offs
Checkout, hostedA session on the server and a redirectEverything enabled, localized by StripeSAQ-AMost stores and SaaS signups; you want Stripe to own the page
Checkout, embeddedA session and a mount pointSame as hostedSAQ-AYou want the page on your domain without building the form
Payment ElementThe page around it and the confirm callEverything enabled, in one componentSAQ-A (iframe)You need full control of layout and the rest of the form
Card ElementAll of itCards onlySAQ-A (iframe)Legacy. Don't start here

Link sits on top of all of them: a customer who has paid with Stripe anywhere can autofill and pay in a tap, and it costs nothing extra. Turn it on.

The decision rule: start with Checkout, and move to the Payment Element only when you can name the layout constraint Checkout can't meet. The Card Element is for integrations that predate 2021. New ones built on it don't get iDEAL, Alipay, or Link, and you'll rebuild the form the day you want any of them.

Stripe Checkout

Stripe Checkout is a hosted payment page. Stripe handles PCI compliance, mobile optimization, and localization; you create a session and redirect the customer to it.

session = stripe.checkout.Session.create(
    mode='payment',
    line_items=[{
        'price_data': {
            'currency': 'usd',
            'product_data': {'name': 'T-shirt'},
            'unit_amount': 2000,
        },
        'quantity': 1,
    }],
    success_url='https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}',
    cancel_url='https://yoursite.com/cancel',
)
# Redirect customer to session.url

Modern Features

Stripe Tax

Stripe Tax calculates and collects tax for global compliance. One parameter turns it on:

session = stripe.checkout.Session.create(
    mode='payment',
    automatic_tax={'enabled': True},
    line_items=[...],
    # Tax calculated automatically based on customer location
)

Cost: 0.5% per transaction (capped) vs $200-$500/month tax service + accountant fees.

Stripe Radar

Radar is Stripe's fraud ML, and it blocks 25-40% of fraud at a false positive rate under 0.05%. It runs on every card payment without configuration.

# Enable with Payment Intents
intent = stripe.PaymentIntent.create(
    amount=1000,
    currency='usd',
    radar_options={'session': radar_session_id}  # Link to frontend session
)

The part you configure is the rules layer, and that's where the money is. Rules are one-liners in the Dashboard that block, allow, request 3D Secure, or send a payment to review:

Block if :risk_level: = 'highest'
Request 3D Secure if :card_funding: = 'prepaid'
Review if :amount_in_usd: > 1000 and :is_off_session: = false

Rules and the review queue come with Radar for Fraud Teams, at $0.02 per screened transaction on standard pricing. A payment that trips a review rule still goes through, but it lands in a queue where someone can refund it before you ship. If you want the hold to be real, pair the rule with manual capture. Two habits pay for the fee on their own. Pass a Radar session from the browser (the radar_options above) so Radar sees device signals and not just the card. And write a 3DS rule before you write a block rule, because a challenge costs you far less than a false decline. Chapter 19 covers the fraud model behind all of this.

Payment Links

Payment Links are no-code payment pages. Create one, share the URL, and you're taking money before the checkout page exists:

link = stripe.PaymentLink.create(
    line_items=[{'price': 'price_1ABCDEFghijk', 'quantity': 1}]  # Replace with your price ID
)
# Share link.url directly with customers

Stripe Identity

Stripe Identity verifies a customer's documents for high-risk transactions or KYC compliance:

session = stripe.identity.VerificationSession.create(
    type='document',
    metadata=
)
# Redirect to session.url for document verification

International Payments

Multi-Currency

intent = stripe.PaymentIntent.create(
    amount=1000,  # 10.00 EUR
    currency='eur',
    payment_method_types=['card', 'sepa_debit', 'ideal', 'bancontact']
)

Regional Payment Methods

RegionMethods
EuropeSEPA, iDEAL, Bancontact, SOFORT
AsiaAlipay, WeChat Pay, Konbini, PayNow
AmericasACH, Boleto, OXXO

Enable in Dashboard > Settings > Payment methods.

Advanced Patterns

Setup Intents (Saving Cards)

A Setup Intent saves a payment method for later without charging it now:

setup_intent = stripe.SetupIntent.create(
    customer=customer_id,
    payment_method_types=['card'],
    usage='off_session'  # For future off-session payments
)

Off-Session Payments

Charge a saved card without the customer present:

intent = stripe.PaymentIntent.create(
    amount=1000,
    currency='usd',
    customer=customer_id,
    payment_method=saved_payment_method_id,
    off_session=True,
    confirm=True
)

Multi-Party Payments (Connect)

Connect splits a payment between your platform and a seller:

intent = stripe.PaymentIntent.create(
    amount=10000,
    currency='usd',
    application_fee_amount=1000,  # Platform takes $10
    transfer_data=
)

That's a destination charge: your platform is the merchant of record for the customer, Stripe moves the money to the connected account, and you keep the application_fee_amount. Three decisions come before that snippet is right for you.

Account type. Standard accounts are full Stripe accounts the seller manages, with Stripe's own onboarding and dashboard. Express accounts give you Stripe-hosted onboarding and a light dashboard. Custom accounts are invisible to the seller and put onboarding, KYC, and disputes on you. Newer API versions describe the same choices as controller properties instead of account types, but the trade-off hasn't moved: the more you hide Stripe, the more you own.

Charge type. Destination charges suit one seller per payment. Separate charges and transfers let you take one payment and move parts of it to several sellers later, which is what a marketplace with a basket needs. Direct charges put the charge on the seller's own account, and the fees and dispute liability go with it.

Cost. Express and Custom accounts run $2 per active account per month plus 0.25% + $0.25 per payout; Standard accounts cost the platform nothing. Chapter 23 covers the onboarding, payout scheduling, and tax reporting that come with running a marketplace.

Security Best Practices

Complete Implementation: See src/examples/9-stripe/advanced_security.py

Store API keys in environment variables and rotate them quarterly. Verify every webhook signature and reject unverified requests outright. Pass idempotency keys on every mutating call so retries can't double-charge. Add client-side rate limiting before Stripe's limits hit you, and log every payment operation for compliance and debugging.

# Idempotent payment creation
intent = stripe.PaymentIntent.create(
    amount=1000,
    currency='usd',
    idempotency_key=f"order_{order_id}"
)

Error Handling

Handle Stripe errors by type:

try:
    intent = stripe.PaymentIntent.create(...)
except stripe.error.CardError as e:
    # Card declined - show user-friendly message
    return
except stripe.error.RateLimitError:
    # Too many requests - implement backoff
    time.sleep(exponential_backoff())
    retry()
except stripe.error.InvalidRequestError as e:
    # Invalid parameters - log and fix code
    logger.error(f"Invalid request: {e}")
except stripe.error.AuthenticationError:
    # Invalid API key - check configuration
    alert_ops_team("Stripe API key invalid")
except stripe.error.StripeError as e:
    # Generic error - retry with backoff
    handle_generic_error(e)

Testing

Use Stripe's test cards:

Card NumberScenario
4242424242424242Success
4000000000000002Decline
4000002500003155Requires 3DS
4000000000009995Insufficient funds

The Stripe CLI Loop

stripe login
stripe listen --forward-to localhost:4242/webhooks/stripe   # prints a whsec_... signing secret
stripe trigger payment_intent.succeeded
stripe trigger invoice.payment_failed
stripe events resend evt_1ABC...   # replay a real event against your local endpoint

listen gives your local server a webhook feed and a temporary signing secret; put that secret in your local environment, not the one from the Dashboard, or every local signature check fails. trigger creates real test-mode objects, so the event you receive carries real IDs you can retrieve. events resend replays an event that already happened, which is the fastest way to reproduce a customer's problem on your machine.

What Test Mode Won't Tell You

Radar scores test cards differently from real ones, real issuers run their own 3DS challenge screens, payout timing is fake, and test-mode webhooks give up after three retries where live mode retries for three days. Before launch, run a few small live charges on your own card, watch them through the webhook endpoint and into your database, then refund them.

Production Checklist

Key Takeaways

You can now take a card payment that clears authentication in Europe, run subscriptions and test a year of them in an afternoon, save cards for later, and split a payment with a seller. You can also say what all of it costs. Stripe's API and docs are the easy part. Get webhook signature verification right first; deduplication, typed error handling, and idempotency on every mutating call come next, and skipping any of them is how you find out the expensive way.


Next up: PayPal - a checkout button hundreds of millions of buyers already trust, attached to an account that can freeze your money for months.

Enjoyed this chapter?

There are 41 more like it.

1,000+ pages and 90+ Python examples that run, plus every future update.

30-day money-back · Instant PDF and EPUB