The three ways to take Stripe payments
Stripe gives you three paths. Most tutorials muddy this choice by treating them as interchangeable. They're not.
Payment Links are hosted URLs Stripe generates for you. You create them in the Stripe dashboard, copy the link, paste it into your site. No code. The payment page lives on checkout.stripe.com. Your customer clicks, leaves your site, pays, gets redirected back. You get a webhook when it succeeds.
Checkout is the same hosted payment page, but you trigger it from your server with an API call. You control when the session starts, what metadata gets attached, and where the user lands afterward. Still hosted on Stripe's domain, still no PCI compliance burden on your end. This is the default choice for 80% of cases.
Elements embeds the payment form directly into your page using JavaScript components. The card field stays on your domain. You style it with CSS variables. You handle the tokenization flow yourself. This is for custom checkout experiences where brand continuity matters more than speed of implementation. It requires more code and more ongoing maintenance when Stripe updates their APIs.
When to use which
Use Payment Links if you're selling one or two fixed-price products and you don't have a developer. A coaching session, a PDF download, a monthly membership. You'll be live in four minutes.
Use Checkout if you have dynamic pricing, user accounts, or any backend logic. Selling SaaS seats where the price depends on team size. Selling event tickets where inventory decrements. Selling anything where you need to record who bought what in your own database before the charge completes.
Use Elements if your brand guidelines won't tolerate a redirect to checkout.stripe.com, or if you need a multi-step funnel where payment happens mid-flow. Luxury goods, high-ticket B2B contracts, anything where dropping the user onto a generic Stripe page would kill conversion. Expect two extra days of development and ongoing tweaks when Stripe deprecates API versions.
Implementing Checkout the right way
Checkout requires two endpoints on your server: one to create the session, one to handle the webhook. The session endpoint runs when the user clicks "Buy now". The webhook endpoint runs when Stripe confirms the payment succeeded.
Your session creation endpoint makes a POST to Stripe's API with line items, success and cancel URLs, and optional metadata. Stripe returns a session ID. You redirect the user to that session. They fill in card details on Stripe's domain. When they complete payment, Stripe redirects them to your success URL. Separately, Stripe sends a webhook to your server with the checkout.session.completed event.
The mistake everyone makes: trusting the success URL redirect to confirm payment. The user can close the tab before the redirect fires. The redirect can fail due to network issues. Stripe can mark the payment as succeeded while the user never sees your confirmation page. The webhook is the source of truth. Always provision access, decrement inventory, or send the download link from the webhook handler, not from the success page.
Your success page should check whether the order is already fulfilled. If the webhook already ran, show "Thanks, check your email". If the webhook hasn't fired yet, show "Processing, refresh in a moment". Never fulfill twice. Never assume the redirect means payment cleared.
Metadata strategy
Stripe lets you attach up to 50 key-value pairs to a Checkout session. Use this to link the Stripe session back to your database. Pass your internal user ID, order ID, or subscription ID. When the webhook arrives, you'll have everything you need to look up the relevant record without querying Stripe again.
Don't pass sensitive data. Don't pass tokens or passwords. Metadata is visible in the Stripe dashboard and in API responses. Pass only the minimum required to join the payment event back to your system's tables.
Test mode vs live mode and how not to mix them
Every Stripe account has two parallel universes: test mode and live mode. Each has its own API keys, its own customer records, its own payment data. Test mode keys start with pk_test_ and sk_test_. Live mode keys start with pk_live_ and sk_live_. You cannot make a test payment with a live key or vice versa.
In test mode, you use fake card numbers. 4242 4242 4242 4242 always succeeds. 4000 0000 0000 0002 always declines. The full list is in Stripe's docs. No real money moves. Webhooks still fire. The Stripe dashboard shows test transactions in a separate view.
In live mode, real cards charge real bank accounts. Stripe takes 2.9% + 30¢ per transaction in most regions. Failed payments stay failed. Disputes and chargebacks become real legal liabilities.
How deployments usually break this: You test locally with test keys. You deploy to production and forget to swap in live keys. Users click "Pay" and get a Stripe error because your server is trying to create live Checkout sessions with a test secret key. Or worse, you hard-code live keys in staging, and a test purchase charges a real card.
The fix is environment variables. STRIPE_SECRET_KEY pulls from .env.local in development, from your production host's config panel in prod. Never commit keys to Git. Never copy-paste keys between environments. Let your deployment pipeline inject the correct key based on the environment name.
Webhook handling without the gotchas
Stripe webhooks are HTTPS POST requests to a URL you specify. You configure the endpoint in the Stripe dashboard under Developers → Webhooks. Stripe signs each request with a secret. Your handler verifies the signature, parses the JSON payload, and acts on the event type.
Webhooks arrive asynchronously. The user's browser has no idea when they fire. Stripe retries failed webhooks for up to three days using exponential backoff. If your server is down, the webhook will retry. If your server returns a 500 error, the webhook will retry. If your server returns a 200 but doesn't actually process the event, the webhook won't retry and you'll miss the payment.
Your webhook handler must be idempotent. Stripe can send the same event twice due to retries or network issues. If event evt_abc123 arrives twice, your code should recognize it's already processed and return 200 without duplicating side effects. Store processed event IDs in your database. Check the ID before provisioning access or charging inventory.
Signature verification
Stripe includes a Stripe-Signature header with every webhook. You use Stripe's SDK to verify it against your webhook signing secret. If verification fails, reject the request. This prevents an attacker from POSTing fake payment events to your endpoint and tricking you into granting access.
The signing secret is different from your API secret key. It starts with whsec_. You get it when you create the webhook endpoint in the dashboard. Treat it like a password. Rotate it if it leaks. Test mode and live mode have separate webhook secrets.
Which events to listen for
For one-time payments with Checkout, listen to checkout.session.completed. This fires when the user completes payment. The event payload includes the session ID, payment status, customer email, and your metadata. Query the session object if you need line item details.
For subscriptions, also listen to invoice.paid, invoice.payment_failed, and customer.subscription.deleted. These handle recurring billing, dunning, and cancellations. Don't assume checkout.session.completed means the subscription will renew. Subscriptions have their own lifecycle and their own events.
Ignore events you don't care about. Stripe sends dozens of event types. Your handler should switch on event.type and return 200 for anything you're not acting on. Returning 4xx or 5xx for unknown events will trigger retries and clutter your logs.
Testing webhooks locally
Stripe can't POST to localhost. You need a tunnel or a CLI tool. Stripe provides the Stripe CLI, which includes a stripe listen command that forwards webhook events to your local server.
Install the CLI, run stripe login, then stripe listen --forward-to localhost:3000/webhook. The CLI will print a webhook signing secret starting with whsec_. Put that in your local .env file as STRIPE_WEBHOOK_SECRET. Now when you create a test Checkout session and complete it, the webhook will fire to your local handler.
You can also trigger specific events manually with stripe trigger checkout.session.completed. This sends a fake event with realistic data. Use it to test your parsing logic without clicking through the payment flow every time.
Going live checklist
Before you flip to live mode, verify these six things:
- Your production environment is using
pk_live_andsk_live_keys, not test keys. - Your production webhook endpoint is registered in the Stripe dashboard under live mode, with the correct URL and the live webhook secret in your server config.
- Your webhook handler verifies signatures and is idempotent (stores event IDs, doesn't double-fulfill).
- Your success and cancel URLs point to production domains, not localhost or staging.
- You've tested a live payment with a real card (yours) and confirmed the webhook fired and the order fulfilled.
- You've confirmed your Stripe account is activated and can receive payouts (bank account connected, business details submitted).
Until you activate your Stripe account, you can create Checkout sessions but Stripe won't actually charge cards. The activation process requires business verification, which can take 24 to 48 hours. Don't launch your product on a Friday and expect Stripe to approve you over the weekend.
Four errors that will break your checkout
Error one: Not handling cancelled sessions. If the user clicks "Back" on the Checkout page, Stripe redirects them to your cancel URL. Your cancel page should let them try again or return to their cart. Don't show an error message that scares them away. 15% of users who cancel will retry if you make it easy.
Error two: Assuming the session amount matches the amount charged. If you enable promotional codes or tax collection in Stripe, the final charge can differ from the line item total you passed. Always read amount_total from the session object in the webhook. Don't calculate it yourself based on your database prices. Stripe is the source of truth for what was charged.
Error three: Creating sessions without expiration handling. Checkout sessions expire after 24 hours. If a user opens the session, walks away, and comes back a day later, the link is dead. Stripe will show an error. Your session creation endpoint should generate a fresh session on every click, not cache and reuse session IDs.
Error four: Forgetting to handle disputed payments. Chargebacks happen. A user pays, you provision access, they dispute the charge with their bank, Stripe debits your account and adds a 15€ dispute fee. Listen to charge.dispute.created and decide whether to revoke access. Not every dispute is fraud; some are friendly fraud, some are legitimate complaints. Have a process.
How Marcus handles this in one prompt
When you tell Marcus "add Stripe payments for my SaaS pricing tiers", it generates the Checkout integration, webhook handler, environment variable setup, and database schema for tracking payment status in a single build. You get test mode working first, with sample card numbers pre-filled in the UI. You get a settings panel where you paste your live keys when you're ready. You get email confirmations triggered from the webhook, not from the redirect.
Marcus defaults to Checkout unless you specify otherwise. It doesn't use Payment Links because it assumes you'll want dynamic metadata and post-payment logic. It doesn't use Elements unless you explicitly ask for embedded forms. It generates idempotent webhook handlers that store stripe_event_id in a processed-events table. It exposes a /admin/payments page where you can see which webhook events arrived and which orders they fulfilled.
The pricing for a site with Stripe integration is the same €29 per project per month on the Builder plan. If you need multi-seat team accounts where Stripe subscription tiers map to user roles, or usage-based billing with metered API calls, that moves you to Studio at €290 per month. But a standard Checkout flow with one-time purchases or simple monthly subscriptions fits in Builder without add-ons.
You're not writing webhook signature verification by hand. You're not debugging why test mode keys are making it into production. You're not wondering which event type fires when a subscription renews. You describe the payment flow in plain language, and Marcus ships the integration that handles the edge cases correctly the first time.