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.
| Item | Cost | Notes |
|---|---|---|
| Cards, US online | 2.9% + $0.30 | The 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 volume | Subscriptions, dunning, proration, test clocks |
| Stripe Invoicing | +0.4-0.5% per paid invoice | If you only send invoices |
| Stripe Tax | +0.5% per transaction | Only in jurisdictions where you're registered to collect |
| Radar | Included | Radar 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 payout | Standard accounts cost the platform nothing |
| Disputes | $15 each | Not refunded when you win |
| Checkout, Payment Element, Payment Links, Link | No 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
- Sign up at stripe.com
- Verify your business (required for live payments)
- Get your API keys from the Dashboard
- 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
| Event | Purpose |
|---|---|
payment_intent.succeeded | Payment completed - fulfill order |
payment_intent.payment_failed | Payment failed - notify customer |
invoice.payment_succeeded | Subscription payment received |
invoice.payment_failed | Subscription payment failed |
customer.subscription.deleted | Subscription canceled |
charge.dispute.created | Chargeback 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.
| Surface | You write | Payment methods | PCI | Use it when |
|---|---|---|---|---|
| Payment Links | Nothing | Everything enabled in the Dashboard | SAQ-A | Selling before the product exists, invoices, one-offs |
| Checkout, hosted | A session on the server and a redirect | Everything enabled, localized by Stripe | SAQ-A | Most stores and SaaS signups; you want Stripe to own the page |
| Checkout, embedded | A session and a mount point | Same as hosted | SAQ-A | You want the page on your domain without building the form |
| Payment Element | The page around it and the confirm call | Everything enabled, in one component | SAQ-A (iframe) | You need full control of layout and the rest of the form |
| Card Element | All of it | Cards only | SAQ-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
| Region | Methods |
|---|---|
| Europe | SEPA, iDEAL, Bancontact, SOFORT |
| Asia | Alipay, WeChat Pay, Konbini, PayNow |
| Americas | ACH, 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 Number | Scenario |
|---|---|
| 4242424242424242 | Success |
| 4000000000000002 | Decline |
| 4000002500003155 | Requires 3DS |
| 4000000000009995 | Insufficient 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.