Decide first where the card gets typed: on Stripe’s hosted Checkout page or inside its Payment Element, never in a form of your own. That one choice keeps card numbers off your servers. How to integrate Stripe correctly comes down to 7 parts: the business account, the keys, card entry, a server-set price, a verified webhook, a pinned API version, and separate test and live setups.
How to integrate Stripe: the seven parts of a correct integration
A correct Stripe integration has 7 parts: an activated business account, keys in the right places, card entry on a Stripe surface, a price decided on the server, a verified webhook that grants access, a pinned API version, and separate test and live setups. A successful test payment proves none of them.
A Stripe integration is the code and settings that let your app create a payment on Stripe and then learn, reliably, that the payment happened. A Stripe payment gateway integration can do the first half well and the second half loosely, and the second half is where, as I read it, money and access go wrong. Integrating Stripe is one piece of the wider SaaS billing process best practices; this page covers the integration itself, in the order I’d build or review it.
The middle column below is my reading of what AI builders tend to produce, not a measured count. The checks column is what tells the two apart, and the section on checking your own integration turns each check into a step you can run in a sandbox.
| Part | What correct looks like | What a builder often wires instead | The check that tells them apart |
|---|---|---|---|
| 1. Business account | Verified in the Dashboard, so live mode works; public business details and the statement descriptor filled in | A sandbox that was never verified, or an account a contractor opened in their own name | Live mode loads, and the account belongs to the business |
| 2. Keys | Publishable key (pk_) in the browser; the server key only on the server, a restricted key (rk_) wherever Stripe’s guidance fits | A secret key in client code or committed to the repo | The built client bundle holds no sk_ or rk_ value |
| 3. Card entry | Checkout, a Payment Link or the Payment Element, so Stripe collects the card | A card form of your own that posts to your endpoint | No field on your domain accepts a card number |
| 4. Price | The server picks the amount from a Price id or your own price table | An amount the browser sends | Editing the amount in the request changes nothing |
| 5. Webhook | Signature checked against the raw body, each event handled once, and this handler grants or removes access | Access granted when the success page loads | Access still arrives when the customer closes the tab before the redirect |
| 6. API version | Set in code and on the webhook endpoint | Nothing set anywhere | The two values match |
| 7. Test and live | Each environment has its own keys, webhook secrets and product ids | Test ids or test keys left in production | Production config holds only live values |
Stripe’s API keys page is plain about row 2: only publishable keys are safe to expose outside your backend, and it recommends restricted keys for most use cases because a secret key cannot have its permissions limited. Read top to bottom, the table is a Stripe payment integration guide for review; for the code behind each row, Stripe’s own documentation is the development guide for payment processing, and each section below links the page it relies on. My working rule is that, at the size of a first SaaS, Stripe integration best practices are these seven parts and nothing more exotic, built or reviewed in the table’s order. If you need to integrate a Stripe payment gateway into an app that already works, start at the top row and stop when every check passes.
Payment Links, Checkout or Elements: which way to integrate Stripe into a website
Stripe offers 3 ways to take a card on a website: Payment Links with no code, Checkout as a Stripe-hosted or embedded page your server opens, and Elements inside your own page. All three keep card entry on Stripe’s side. For a first SaaS integration, I’d use Checkout.
Stripe’s integration options page lays the choices out as five columns, from “No code required” to “Most coding”. When you integrate Stripe into a website, the decision is how much of the payment page you want to own.
| Surface | Code you write | Where the card is typed | What you give up | Fits when |
|---|---|---|---|---|
| Payment Links | None (“No code required”) | A Stripe-hosted page | Limited customization; the app still needs a webhook to unlock anything | One product, or a test before launch |
| Checkout, hosted or embedded | Your server creates a Checkout Session (“Low coding”) | A Stripe-hosted page, or Stripe’s form embedded on your site | Limited customization | A SaaS subscription |
| Elements with Checkout Sessions | ”More coding” | The Payment Element on your own page | More to get right in your own page | You need the form inside your own page |
| Advanced integration (Elements with PaymentIntents) | “Most coding” | The Payment Element on your own page | Discount logic, tax calculation and currency conversion become your code | You want to own every part of checkout |
What Stripe calls an advanced integration is the Elements path with Payment Intents handled by your own code. Stripe’s online payments page says that if you use PaymentIntents, “you must manually build equivalent features in your code, including discount logic, tax calculation, and currency conversion.” A first SaaS rarely needs that. Integrating Stripe into a website through a Payment Link is real too, and it is the quickest of the three, but if the app must unlock a plan or a feature after the payment, a webhook is still needed: Stripe’s fulfillment guide says it applies to Payment Links as well, because “Payment Links use Checkout”. The rest of this page assumes Checkout or Elements.
Why it matters: what an AI builder wires, and the half it skips
A generated integration usually reaches a successful test payment, and that proves the happy path and nothing else. The tutorial-style Stripe integration example ends at that first green checkmark, which is the point this page starts from.
Across the apps I audited, 10 of the 21 third-party apps trusted the client: the server accepted whatever the browser asserted. Those 21 were the 11 public apps and the 10 held-out apps I audited in June and July 2026, the second group audited blind, and they are a selected set, not a random sample or a rate for AI-built apps in general. At checkout, trusting the client means a price the customer can edit; that is my reading of the pattern, not a separate count. In the same audits, only 1 of the 21 third-party apps, a healthcare FHIR hub, was credited with a real test suite, and even it skipped sign-up, login and the payment webhook. Three of the audited apps had a real secret permanently in git history: a webhook signing secret, a live AI-provider key, and a Stripe test key with its webhook secret.
Three gaps follow from that, as I see it:
| What the generated integration does | What is missing | What it costs you |
|---|---|---|
| Takes the price from the browser | A price the server looks up | Revenue on every order someone edits |
| Grants access when the success page loads | Access granted from a payment the server confirmed | The failure modes in the six-ways article’s success-page point, linked below |
| Never handles renewals or cancellations | The subscription events | Access that outlives the payment |
Picture a founder’s app, built on an AI builder, that sells a subscription through Stripe Checkout, where the only code that unlocks the plan runs on the success page. A customer pays and loses the connection before that page loads; Stripe records the payment, and the app never unlocks the plan. Stripe’s own fulfillment page warns about exactly this: “a customer can pay successfully and then lose their internet connection before your landing page loads,” and it says webhooks are required if you sell subscriptions.
The lesson I take from it: a payment Stripe recorded and the app never saw is still a customer you charged, and the webhook is what makes the two records agree. Some builders document the success-page path in their own guides; the server-side section below quotes one. Why a redirect fails as proof in both directions is the success-page point of six ways a vibe-coded checkout leaks money, along with the other leaks it lists and a ten-minute check. The seven conditions to meet before the first real card are in before you turn on payments. If nobody on the team can do this work, what the job contains is covered in hiring someone to add payments to your app.
How it works: the account, the card form, the server, the version and the framework
Five pieces, in the order I’d build them: the account, where the card is typed, what the server owns, the API version, and the framework details.
How to use Stripe for business: the account setup an integration assumes
A Stripe account set up for an integration has 7 settings in place before any code runs: a verified business with a payout bank account, public business details including the statement descriptor, two-factor authentication on every login, products and prices in both modes, Dashboard-managed payment methods, customer emails switched on, and a tax decision.
What Stripe does for an app is take the card or wallet payment and then send funds from your available balance to your bank account as payouts. Getting started with Stripe happens in a sandbox; live mode waits until you verify your business and complete its activation requirements. Most of how to use Stripe well is in the Dashboard rather than the code, and the Stripe settings below are the ones an integration quietly depends on. Stripe’s account setup guide covers the verification itself.
- 01 Verify the business in the Dashboard and add a payout bank account in Payout settings, so live keys can take real payments
- 02 Fill in the public business details customers see: business name, website, support email and phone, support site URL, and statement descriptor text
- 03 Turn on two-factor authentication for every login, ideally with a passkey or security key, and give contractors team-member access rather than the owner's login
- 04 Create products and prices in the sandbox, then again in live mode, because objects in one mode are not available in the other
- 05 Manage payment methods in the Dashboard with dynamic payment methods instead of listing them in code
- 06 Switch on customer emails for successful payments and refunds, and for failed subscription payments
- 07 Decide on tax settings, even if the decision for now is to wait
The statement descriptor matters more than it looks: Stripe’s guide says that if a customer can’t recognize one of your payments, they might dispute it. Stripe recommends passkeys or security keys for two-factor authentication and treats SMS as a last resort. The Stripe setup work is the same when a builder wires the code. Lovable’s guide for connecting your own Stripe account says business verification, payouts, taxes and disputes all happen in Stripe. Base44 sets the integration up in a test environment first and creates live API keys once the Stripe account finishes activating.
Payment Element and hosted fields: keeping card entry out of your own form
The Payment Element is Stripe’s embeddable card and wallet form, served by Stripe inside an iframe on your page. Card numbers travel from the browser to Stripe without passing through your server, which is what shrinks a small team’s PCI DSS obligations.
Stripe describes the Payment Element as a UI component that lets you “accept more than 100 payment methods, validates input, and handles errors,” and its Stripe.js reference says Stripe “inserts an iframe into each div to securely collect payment information.” Hosted Checkout does the same job with a whole page. What comes back to your server is non-sensitive: Stripe’s guide lists the card type, the last four digits and the expiration date as information that “isn’t subject to PCI compliance.”
Where the card is typed decides how much of PCI DSS lands on you. Stripe’s integration security guide says a business that handles card data directly “might be required to meet more than 300 security controls in PCI DSS,” while its low risk integrations send payment information to Stripe without it passing through your servers. Stripe’s security page adds that it checks your integration method and tells you which PCI validation form, the Self-Assessment Questionnaire, to use, with help in the Dashboard for Elements and Checkout users. What the standard itself asks of you is a separate subject: the PCI DSS requirements themselves.
Four things undo the benefit. Building your own card fields that submit to your server brings card numbers back onto it. Logging full request bodies on the payment route can, I’d argue, copy whatever passes through it into your log store. Third-party scripts on the checkout page carry their own risk, which Stripe’s guide states directly: “If they’re ever compromised, an attacker could execute arbitrary code on your page.” And a payment page served over anything older than TLS 1.2 breaks Stripe’s rule that payment pages use TLS 1.2 or above. One more note for older code: Stripe calls the Card Element “a legacy integration” and strongly recommends the Payment Element for card payments too.
The server side: the session, the price and the webhook that grants access
The server owns 3 things in a Stripe integration: the server key, the price, and access. It creates the session from a Price id, never a browser-sent amount, and grants access only from a payment it confirmed, with a signature-verified webhook as the path that always runs. The success page redirect is a thank-you, not proof of payment.
The flow, in six steps:
- The browser asks your server to start a purchase and sends only what was chosen, such as a plan key, never an amount.
- The server looks up the Price id, creates the Checkout Session (or a PaymentIntent) with its server key, puts your user id in
client_reference_idormetadata, and sends an idempotency key, as Stripe’s page on idempotent requests describes, so a retried request cannot create a second session. - The customer pays on Stripe’s surface.
- The success page grants nothing by being visited. If it calls your server to speed things up, the server retrieves the Checkout Session and checks its
payment_status, as the six-ways article linked above explains. - Stripe sends
checkout.session.completedto your webhook endpoint, and for subscriptions also events such ascustomer.subscription.createdandinvoice.paid. - The handler verifies the signature against the raw body with that endpoint’s own secret, records the event id so a redelivery does nothing, and only then updates the account.
Stripe’s idempotency layer saves the result of the first request for each key, accepts keys on every POST request, and may prune a key once it is at least 24 hours old. In Node, the second step looks like this (my sketch, built from Stripe’s documented parameters):
const session = await stripe.checkout.sessions.create(
{
mode: 'subscription',
line_items: [{ price: priceIdFor(planKey), quantity: 1 }], // looked up on the server
client_reference_id: user.id,
success_url: 'https://example.com/after-checkout?session_id={CHECKOUT_SESSION_ID}',
},
{ idempotencyKey: purchaseAttemptId },
);
Stripe’s page on fulfilling orders is direct about the order of trust: “You can’t rely on triggering fulfillment only from your checkout landing page, because it’s not guaranteed customers visit that page,” and automatic fulfillment with webhooks “is required if you sell subscriptions or accept payment methods with delayed success notification.” It also recommends calling the same fulfillment function from the landing page, because webhooks can sometimes be delayed. Base44’s payments guide, by contrast, tells builders to confirm the payment and update the account on the success page “instead of relying on webhooks alone.”
Webhook signing secrets are per endpoint and separate from API keys, so the same URL has a different secret in the sandbox and in live mode. When a delivery shows a 200 and the account still did not change, work through the checks for a Stripe webhook that does not update the database in order. In live mode Stripe can retry a failed delivery for up to 3 days with exponential backoff, and it does not guarantee the order events arrive in. So the handler must be safe to run twice and out of sequence; that is my conclusion from Stripe’s two rules.
The topics underneath this flow go deeper than one page can: what is a webhook, why Stripe signature checks fail, how to make a webhook handler idempotent, the subscription lifecycle in Stripe and how to enforce plan limits on the backend. Renewals that bounce are covered in how to handle failed subscription payments. Two neighbors of the integration matter as well: how to set up the Stripe customer portal, for a self-serve billing page, and secrets management best practices, for where the server key and webhook secret should live.
Stripe API versions: pinning one, and what an upgrade changes
A Stripe API version applies in 3 places: the account default, the version your code sends, and each webhook endpoint. Pin it in code and on every endpoint, because an endpoint created without one follows the account default, and a library upgrade can move the version your code sends.
Stripe names each version by its release date plus a release name; the current one, as of 2026-10-03, is 2026-09-30.endive. Since the 2024-09-30.acacia release, Stripe’s API versioning page says, new versions ship monthly with no breaking changes, and twice a year a new major release starts with a version that has breaking changes. Your account’s default gets set the first time you make an API request, and you view and upgrade it in Workbench.
| Where the version is set | What it controls | If you leave it unset |
|---|---|---|
| Account default, in Workbench | Requests that send no version, and webhook endpoints created without one | Set by your first API request |
Your code: the library’s apiVersion option, or the Stripe-Version header | The shape of the requests and responses your code works with | Recent stripe-node uses the version that was latest when that release shipped; stripe-node before v12 uses the account default |
Each webhook endpoint (api_version, or snapshot_api_version on an event destination) | The shape of the event payloads that endpoint receives | The account default; it can be set only when the endpoint is created |
Why an owner cares: a request made on one version and a webhook rendered on another can disagree about field names, and a default upgrade changes every unpinned endpoint at once (my reading of the table). Stripe’s own advice in its API upgrades guide is to “specify the API version that you’re integrating against in your code instead of relying on your account’s default API version.” Record both values in the repo so the next person can see them.
An upgrade, done the way Stripe’s guide lays it out, has five moves. Read the entries between your version and the target in the Stripe changelog, where versions with breaking changes carry a “Breaking changes” label. Upgrade the library in a branch and run the payment tests in a sandbox. Create a second webhook endpoint on the new version at the same URL with a query parameter, so every event arrives twice and you can compare payloads. Switch processing to the new endpoint, with the old one returning a 400 so Stripe re-sends its events if you need to revert. While both are live, the handler must be idempotent, because Stripe delivers each event to both. Once the new endpoint processes events correctly, disable the old one. The newer /v2 namespace is a separate thing from a dated version: it is a set of endpoints built on different design patterns, while most of the API stays in /v1.
Next.js Stripe integration and Angular notes
A Next.js Stripe integration keeps the server key in Route Handlers and reads the webhook’s raw body before any parsing. An Angular single-page app has no server of its own, so it needs a separate backend or serverless function for the session and the webhook. The publishable key is the only key a browser gets.
The rule that never changes: anything holding the server key runs on a server. In Next.js, Next.js environment variables prefixed NEXT_PUBLIC_ are inlined into the JavaScript sent to the browser, so only the publishable key may carry that prefix. Next.js Route Handlers read the body with standard Web API methods, so the webhook handler calls request.text() and passes that string to Stripe before anything parses it as JSON. Stripe’s signature page says some frameworks edit the body by adding whitespace, reordering keys, converting the string to JSON or changing the encoding, which makes verification fail, and that the error means at least one of the three inputs (the body, the Stripe-Signature header, or the endpoint secret) is wrong.
For Stripe payment gateway integration in Angular, the session and the webhook live in a separate backend, and the browser side loads Stripe.js through the @stripe/stripe-js package rather than a copied script; Stripe’s reference says the script “should always be loaded directly from https://js.stripe.com, rather than included in a bundle or hosted yourself.” A React app without a framework server is the same case as Angular, with Stripe’s React components on top.
| Framework | Where the server key may live | The webhook gotcha | Client package |
|---|---|---|---|
| Next.js (App Router) | Route Handlers and other server code; never a NEXT_PUBLIC_ variable | Read the body with request.text() before any parsing | @stripe/stripe-js, plus @stripe/react-stripe-js for React components |
| Angular | A separate backend or serverless function | The backend must hand Stripe the unparsed body; in Express, express.json() goes after the webhook route | @stripe/stripe-js |
| React without a framework | A separate backend or serverless function | Same as Angular | @stripe/stripe-js and @stripe/react-stripe-js |
| Supabase Edge Function as the backend | The function’s environment (Supabase’s example reads the webhook secret with Deno.env.get) | verify_jwt defaults to true; for a Stripe webhook keep verify_jwt = false and verify Stripe’s signature inside the handler | Not applicable |
On Supabase, the Edge Functions guide puts it plainly: external providers such as Stripe “sign the request body with their own shared secret,” so the function skips Supabase’s own check and verifies the provider’s signature inside the handler.
How to check your own integration
A Stripe integration is checked with 7 tests: no secret in the client bundle, an edited amount that changes nothing, no card field on your domain, access that arrives with the tab closed, a resent event with one effect, matching pinned versions, and only live ids in production.
Each check runs in a sandbox against your own app, and each procedure comes from Stripe’s documentation or from what the code itself shows; keep a screenshot or a log line as the evidence for each one.
- 01 Search the built client bundle for sk_live_, sk_test_, rk_live_, rk_test_ and whsec_ and expect zero hits; a publishable pk_ key is expected there
- 02 Open the network tab at checkout and look for an amount in the request your browser sends; change it and confirm the charge does not change
- 03 Confirm that no form field on your own domain takes a card number
- 04 As a signed-in test user, pay with a test card yourself and close the tab before the redirect; access must still arrive, because the webhook grants it
- 05 Take the event that check 4 produced, resend it to your endpoint from the Stripe CLI or the Dashboard, and confirm exactly one effect; then post a captured delivery's body altered by one character, with its original signature header, within five minutes, and confirm a signature-mismatch refusal
- 06 Read the API version set in code and the version on the webhook endpoint, and confirm they match
- 07 List test and live keys, webhook secrets and price ids side by side and confirm production holds only live ones
For check 5, the CLI command is stripe events resend <event_id> --webhook-endpoint=<endpoint_id>, which works for up to 30 days after the event was created; the Dashboard’s Resend button works for up to 15 days. A stripe trigger fixture sends a signed test event, but it belongs to no user of your app, so in my view it tests signature handling and not the account update. The five-minute window comes from Stripe’s webhooks guide: its libraries “have a default tolerance of 5 minutes between the timestamp and the current time,” so an altered body posted inside that window fails on the signature, not on age. What a mismatch error means, and the three inputs behind it, is on Stripe’s webhook signature page. The reference for resending is stripe events resend.
The full cutover list is in the Stripe test mode to live checklist and the payment go-live checklist. Keep Stripe’s own go-live advice in view too: it recommends logging important data on your end, because your own logs serve as a backup when a problem would stop Stripe from logging the request.
When we run the Production Hardening Sprint, deliverable 5.1, Webhook signature verification, is verified this way: confirm valid events succeed and invalid or altered payloads are rejected. The fifth check above is the same test in its simplest form.
Where the sprint fits
In the Production Hardening Sprint, the payments area covers five parts of this page. Deliverable 5.1 is to verify the payment provider’s signatures before processing webhook payloads ; 5.2 is to make every webhook handler safe to repeat, including concurrent delivery and its downstream side effects ; 5.3 is to handle creation, updates, cancellation, payment failure, refunds, and disputes, including delayed or out-of-order events ; 5.4 is to enforce paid-plan permissions and usage limits in backend actions ; and 5.5 is to verify test and live credentials are isolated by environment. New features that change the product’s core capabilities are separate work; the package includes only the supporting interfaces the listed controls need, such as session management, account deletion and billing self-service. The fee covers our engineering work; hosting, paid tools, and API usage remain in your accounts, and we explain any required third-party costs before enabling them. Every deliverable and its verify step is listed in the published scope.
Common questions about a Stripe integration
Is Stripe integration free?
Mostly, yes: the integration itself has no fee. Stripe’s pricing page, as shown on 2026-10-03, lists “No setup fees, monthly fees, or hidden fees” and charges per successful transaction, 2.9% + 30¢ for domestic cards in its US-dollar pricing. The real cost of integrating is the engineering time to get the seven parts right (my reading).
What is the difference between Stripe Checkout and Stripe Elements?
Checkout is a whole payment page that your server opens with a Checkout Session, hosted by Stripe or embedded on your site; Elements, such as the Payment Element, are form components you place inside a page you build. Stripe’s integration options rate Checkout “Low coding” with “Limited customization” and Elements “More coding” with “Extensive customization with Appearance API”. Both keep card entry on Stripe’s side, and for Elements Stripe recommends the Checkout Sessions API for most integrations.
Can AI agents build Stripe integrations?
Yes, to a working happy path, and Stripe documents how: Stripe’s docs for building with agents offer an agent plugin that bundles its MCP server and agent skills. The seven parts above are what to check afterwards, for the client-trust reason given in the audit numbers earlier on this page.
Stripe’s MCP server gives agents tools to work with the Stripe API and search Stripe’s documentation; for Claude Code, Stripe’s page lists claude mcp add --transport http stripe https://mcp.stripe.com/, and what that connection can reach is covered in the Stripe MCP server for Claude Code.
Do I need an LLC for a Stripe account?
No. Stripe’s support pages say you can sell without a separate business entity by signing up as a sole proprietorship, or as a partnership if you operate with others. For a US account, Stripe’s US account requirements say it verifies your legal entity name, legal entity type (such as Sole Proprietor or LLC), EIN, SSN (or ITIN) and business address. This is not legal or tax advice.
How can I embed a Stripe payment link on my website?
Open the Payment Link in the Dashboard, click Buy button, and paste the embed code Stripe generates (a <script> tag and a <stripe-buy-button> web component) into your page; the buy button uses your account’s publishable key. If the app must unlock anything after the payment, you still need the webhook, because Payment Links use Checkout.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase