phpguzzle.org
enrudees
Stripe – Docs

Stripe Checkout is the quickest way to take a payment without building a card form yourself. Your backend creates a Checkout\Session, hands the URL to the customer, and Stripe calls your webhook once the money lands. The UI ships in three shapes: hosted (page on Stripe’s domain), embedded (iframe on your page), and custom (Checkout primitives you lay out yourself, GA since early 2025).

This guide walks through the PHP side of all three. Every example uses the stripe-php SDK; the client-side glue is just enough to make the examples runnable.

Does Stripe support PHP?

Yes. stripe/stripe-php is a first-class, Stripe-maintained SDK on Packagist. Install it with Composer:

composer require stripe/stripe-php

Set your secret key once at bootstrap and stop thinking about it:

\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

Use sk_test_... during development and a separate sk_live_... in production. Every example below assumes the SDK is loaded and the key is set. If you have not done the initial setup yet, the payment-integration guide covers Composer install, keys, and the very first PaymentIntent.

Checkout Session vs PaymentIntent vs Payment Element

These three names show up all over the docs, and a good chunk of SO threads come from people mixing them up.

  • PaymentIntent is the low-level payment object. It holds amount, currency, status, and a secret. Every Stripe payment creates one under the hood.
  • Payment Element is a JavaScript UI component. You create the PaymentIntent yourself, mount the Element on your page, and handle the confirm call in JS.
  • Checkout Session is a higher-level object that wraps the whole flow: UI, payment method collection, tax, shipping, coupons. You create the Session, hand the URL to the customer, Stripe runs the page. It creates a PaymentIntent for you internally.

Picking one is a tradeoff between control and implementation time. If “a branded page with line items, taxes, and Apple Pay” covers what you need, reach for Checkout. If you need a bespoke multi-step flow with custom validation, use Payment Element against a PaymentIntent you manage yourself. Start with Checkout; move to Elements only when Checkout genuinely cannot do what the product needs.

Checkout also carries its own webhook event, checkout.session.completed, which fires once the session is paid. payment_intent.succeeded still fires too, but checkout.session.completed is the one you listen to when you started the flow from a Session.

The three UI modes

Checkout ships in three shapes:

ModeWhere the UI livesHow the customer returns
HostedOn Stripe’s domain (checkout.stripe.com)Browser redirect to success_url
EmbeddedInline iframe on a page you ownreturn_url with {CHECKOUT_SESSION_ID}
CustomYour page, your HTML, Checkout primitivesreturn_url with {CHECKOUT_SESSION_ID}

Hosted is the default and what most guides show. Embedded went GA in late 2023 and is the standard when you want the Checkout UI inside your own page without the redirect round-trip. Custom Checkout (ui_mode: 'custom') went GA in early 2025 and is the right choice when the embedded iframe is too opinionated for the design system you need to match.

A minimal hosted Session

Here is a working endpoint that creates a Session and redirects the browser to Stripe:

<?php
require __DIR__ . '/vendor/autoload.php';

\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$session = \Stripe\Checkout\Session::create([
    'mode' => 'payment',
    'line_items' => [[
        'price' => 'price_1OExampleHandle',
        'quantity' => 1,
    ]],
    'success_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/cart',
    'metadata' => [
        'order_id' => $orderId,
    ],
]);

header('Location: ' . $session->url, true, 303);
exit;

That is the whole backend. The client does nothing except follow the redirect.

mode decides what kind of transaction this is. payment is a one-off charge. subscription bills recurring prices. setup saves a payment method without charging (useful for “add card to account” flows). Passing payment_intent_data alongside 'mode' => 'subscription' returns invalid_request; use subscription_data in that case.

line_items supports two shapes. The recommended one is a price ID you created in the Dashboard (or via the API). Inline price_data still works, but prices in Dashboard let non-developers change copy and amounts without shipping code:

// Dashboard-managed price (recommended)
'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],

// Inline price (fine for one-offs, dynamic amounts)
'line_items' => [[
    'price_data' => [
        'currency' => 'usd',
        'product_data' => ['name' => 'Pro plan, one month'],
        'unit_amount' => 1999,
    ],
    'quantity' => 1,
]],

Amounts are in minor units. 1999 means 19.99 USD. Zero-decimal currencies (JPY, KRW, HUF) take the full integer amount – do not multiply by 100 for those.

success_url is required in hosted mode; cancel_url is optional (if omitted, Checkout shows a generic “Back” button that returns the customer to a neutral cancelled-session page). The {CHECKOUT_SESSION_ID} token is literal text; Stripe substitutes it when redirecting. On your thank-you page, retrieve the Session by that ID and verify status === 'complete' and payment_status === 'paid' before fulfilling the order – a user bookmarking the success URL should not count as a paid order.

metadata is an opaque key-value bag that travels with the Session and the webhook event. Use it to stash your internal IDs so the webhook handler knows which order to mark paid. The metadata section below covers where it does and does not propagate.

Embedded Checkout

The embedded variant replaces the redirect with an inline iframe. The backend change is tiny: swap success_url/cancel_url for ui_mode and return_url, and return the client_secret to the frontend:

$session = \Stripe\Checkout\Session::create([
    'ui_mode' => 'embedded',
    'mode' => 'payment',
    'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],
    'return_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'metadata' => [ 'order_id' => $orderId ],
]);

header('Content-Type: application/json');
echo json_encode(['clientSecret' => $session->client_secret]);

On the page you serve the form from, mount the iframe with Stripe.js:

<div id="checkout"></div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('pk_live_...');

fetch('/create-checkout-session.php', { method: 'POST' })
    .then(r => r.json())
    .then(({ clientSecret }) => stripe.initEmbeddedCheckout({ clientSecret }))
    .then(checkout => checkout.mount('#checkout'));
</script>

Two things often catch people out here. First, you need a publishable key (pk_, not sk_) in the browser. Second, the embedded Checkout still redirects the whole page to return_url when the payment completes – the iframe is not a SPA. If you want the customer to stay on the page after paying, design return_url as a thin landing that closes the modal or updates UI via JS.

redirectToCheckout is gone

A lot of old Stack Overflow answers show stripe.redirectToCheckout({ sessionId }) as the client-side flow. That method was removed from Stripe.js in the 2025-09-30 release. The current advice is:

  • Hosted mode: server-side Location redirect (as in the first example).
  • Embedded mode: stripe.initEmbeddedCheckout({ clientSecret }).

If a tutorial you are copying from is calling redirectToCheckout, that is your signal to keep reading more recent docs.

customer vs customer_email

Small footgun. Checkout can prefill the email field two ways:

// Option A: attach an existing Customer object
'customer' => 'cus_ExistingCustomerId',

// Option B: prefill the form for a new Customer
'customer_email' => $user->email,

If you pass customer and the Customer already has an email, Checkout prefills it from the Customer record and locks the field – the shopper cannot edit it. customer_email is ignored in that case. If you pass customer_email without customer, Checkout prefills an editable email and creates a new Customer when the Session completes. Picking one of the two consciously avoids a recurring thread on the Stripe community forum about locked email fields.

Metadata: the bridge to your webhook

The metadata question is a recurring Stack Overflow topic, usually phrased as “my metadata is not in the event”. Two things trip people up:

  1. Metadata belongs on the Session, not on line items. The API accepts line_items[].metadata, but it stays on the line item – it does not surface on the event payload most integrators listen to, and it does not propagate to the PaymentIntent. Put your order ID, user ID, and anything else you need to correlate on the Session top-level metadata:

    'metadata' => [
        'order_id' => $orderId,
        'user_id'  => $userId,
    ],
  2. Metadata on the Session does not automatically land on the PaymentIntent. If your webhook listens for payment_intent.succeeded and reads $event->data->object->metadata, it will be empty unless you also pass payment_intent_data.metadata:

    'payment_intent_data' => [
        'metadata' => [ 'order_id' => $orderId ],
    ],

    The simpler path: listen for checkout.session.completed and read metadata directly off the Session object in the event. No duplication.

Reacting to a completed Session

When the customer finishes paying, Stripe fires checkout.session.completed to your webhook endpoint. That event is the canonical signal that the Session is done. payment_intent.succeeded also fires, but for Checkout flows, prefer the higher-level event: it includes the Session object, which gives you the metadata and customer_details in one place.

// inside the webhook endpoint (see the webhooks guide for signature verification)
if ('checkout.session.completed' === $event->type) {
    $session = $event->data->object;
    $orderId = $session->metadata->order_id ?? null;
    $paymentStatus = $session->payment_status; // 'paid', 'unpaid', 'no_payment_required'

    if ('paid' === $paymentStatus && $orderId) {
        $orderRepo->markPaid($orderId, $session->id);
    }
}

Signature verification, raw-body capture, Laravel/Symfony quirks, and idempotency all live in the webhooks guide. If you have not wired up the webhook endpoint yet, start there – building Checkout without a webhook means you find out about failed payments by customers emailing you.

One more subtlety: checkout.session.async_payment_succeeded and checkout.session.async_payment_failed fire for delayed payment methods (SEPA debit, Bacs, some bank debits) that settle hours or days after the Session completes. If you accept any delayed method, treat the Session as “processing” on completed and only fulfill on the async success event.

Retrieving the Session on your success page

After a hosted-mode redirect, your thank-you page receives a session_id query parameter. Load the Session to decide what to show:

<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$sessionId = $_GET['session_id'] ?? '';
if (1 !== preg_match('/^cs_[a-zA-Z0-9_]+$/', $sessionId)) {
    http_response_code(400);
    exit('Bad session id');
}

$session = \Stripe\Checkout\Session::retrieve([
    'id' => $sessionId,
    'expand' => ['payment_intent', 'customer'],
]);

if ('complete' === $session->status && 'paid' === $session->payment_status) {
    echo "Thanks, {$session->customer_details->email}";
} else {
    echo 'Payment is still processing.';
}

The regex on session_id is not optional. Checkout Session IDs are opaque, and a user who swaps in someone else’s ID should see a clean error, not an exception trace. Sanity-checking the format before the API call is the cheap kind of defence.

Do not use this page as the source of truth for “order paid”. The webhook is the source of truth. The success page is just UI.

Expiration

Checkout Sessions expire 24 hours after creation. That is a hard cap: you cannot push it further. After expiry, the URL returns an error and the Session transitions to expired. Stripe fires a checkout.session.expired event so you can release held inventory, cancel the draft order, or email a reminder.

You can shorten the default by passing expires_at as a Unix timestamp (minimum 30 minutes from creation):

'expires_at' => time() + 30 * 60, // 30 minutes from now

A short expiration is the intended pattern for inventory holds: create a Session, reserve the stock, release the reservation on checkout.session.expired if the customer walks away. For a link that needs to outlive 24 hours, reach for a different primitive – a PaymentLink (shareable URL, no expiry) for pay-now pages or an Invoice for pay-when-ready.

You can also expire a Session programmatically, which is handy when a user navigates away and you want to free inventory without waiting for the timeout:

\Stripe\Checkout\Session::expire($session->id);

Troubleshooting

The Checkout page is blank / not loading

First thing to check: your Content Security Policy. Stripe’s embedded iframe loads scripts from https://js.stripe.com and opens frames at https://checkout.stripe.com. If your CSP does not allow them, the page mounts an empty iframe and the browser console fills up with CSP violations. The fix is on your side:

frame-src https://js.stripe.com https://checkout.stripe.com;
script-src https://js.stripe.com;
connect-src https://api.stripe.com;

For hosted mode, the other common culprit is an AdBlock rule that matches checkout.stripe.com. Nothing you can do about that server-side; the mitigation is monitoring your checkout.session.completed rate against Session creation rate and alerting on a gap.

”No such checkout_session”

You are hitting Stripe in live mode with a Session ID that was created in test mode, or the other way around. Check which key (sk_test_ vs sk_live_) you initialised the SDK with on both the creation endpoint and the success-page endpoint. Mismatches here are especially easy to make when you deploy a partially updated environment.

Metadata is missing on the event

See the metadata section above – either the metadata is on line_items[].metadata (does not surface on the event), or the webhook is listening for payment_intent.succeeded without payment_intent_data.metadata set on the Session. Switch the listener to checkout.session.completed and the problem goes away.

Payment methods are not showing

Enabled payment methods depend on currency, country, and your Stripe account configuration. Check the Payment Methods settings in the Dashboard for the account (not just for the individual Session). Google Pay and Apple Pay additionally require domain verification; the Dashboard has a self-service UI for that, and until the domain is verified they simply do not render.

Subscriptions in a single line

For recurring billing, switch mode from payment to subscription and point line_items at a recurring price. Subscription-specific concerns – trial periods, proration, cancellation flows, billing portal – live in the subscriptions guide. The Checkout part is this small:

$session = \Stripe\Checkout\Session::create([
    'mode' => 'subscription',
    'line_items' => [[ 'price' => 'price_monthly_handle', 'quantity' => 1 ]],
    'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/pricing',
]);

What next

  • Wire up signature verification and idempotent handlers: Stripe webhooks in PHP.
  • Move delicate flows to a custom UI: Payment Element on a PaymentIntent you control, with Stripe Elements.
  • For recurring billing, Stripe subscriptions in PHP covers lifecycle events, proration, and the customer portal.
  • If you are still on the shared-hosting-without-composer path, the init.php fallback in the payment-integration guide applies to Checkout too – Checkout\Session::create works the same way.

The interesting part of a payment system is the webhook handler and the reconciliation story, not the Session code. See the webhooks guide for the handler that listens to checkout.session.completed and marks orders paid without duplicating on retry.

Spotted something inaccurate on this page?

Report an error