Skip to main content
    Courses/PHP/Payment Gateways

    Lesson 38 • Advanced

    Payment Gateways 💳

    By the end of this lesson you'll be able to take a real card payment with Stripe — creating the charge on the server, keeping card data entirely out of your code, and using a verified webhook to fulfil the order reliably.

    What You'll Learn in This Lesson

    • Explain the Stripe payment flow: Checkout Session / PaymentIntent
    • Keep raw card data out of your server entirely (PCI, Stripe.js/Elements)
    • Create a charge on the server with the price you control
    • Fulfil orders from a webhook and verify its signature
    • Make money-moving requests safe with idempotency keys
    • Issue refunds, switch test↔live keys, and store only minimal data

    1️⃣ The Flow: You Never Touch the Card

    A payment gateway like Stripe is the service that actually moves money between a customer's card and your bank. The golden rule: raw card numbers must never reach your server. If they did, you'd fall under the full weight of PCI DSS (the card industry's security standard) — months of audits you do not want. Instead, the card details go straight from the customer's browser to Stripe via Stripe.js / Elements (a hosted card form) or Stripe's hosted Checkout page. Stripe hands you back a harmless token, and your server works with that.

    Two objects drive every payment. A PaymentIntent is Stripe's record of one payment as it moves through its states (needs action → processing → succeeded). A Checkout Session is a higher-level wrapper that builds Stripe's entire hosted pay-page for you and creates a PaymentIntent for you behind the scenes. Start with Checkout — it's the least code and the most secure.

    2️⃣ Creating the Charge on the Server

    Your PHP backend creates the session with the price you decide and a couple of redirect URLs, then sends the customer to Stripe. Notice the price lives in server code, and amounts are in the smallest currency unit — cents for USD, so 4900 means $49.00. Your secret key is read from an environment variable, never written in the file.

    Create a Stripe Checkout Session
    <?php
    // Create a Stripe Checkout Session, then redirect the customer to Stripe.
    // Install once:  composer require stripe/stripe-php
    require_once "vendor/autoload.php";
    
    use Stripe\\StripeClient;
    
    // Your SECRET key lives ONLY on the server, loaded from an env variable —
    // never hard-code it and never send it to the browser.
    $stripe = new StripeClient(getenv("STRIPE_SECRET_KEY"));
    
    // You set the price on the SERVER. Never trust an amount sent by the browser.
    $session = $stripe->checkout->sessions->create([
        "mode" => "payment",
        "line_items" => [[
            "quantity" => 1,
            "price_data" => [
                "currency"     => "usd",
                "unit_amount"  => 4900,                 // 4900 = $49.00 (amounts are in CENTS)
                "product_data" => ["name" => "Pro Plan"],
            ],
        ]],
        "success_url" => "https://myapp.com/success?session_id={CHECKOUT_SESSION_ID}",
        "cancel_url"  => "https://myapp.com/cancel",
    ]);
    
    echo "Session created: " . $session->id . "\n";   // e.g. cs_test_a1B2c3...
    echo "Redirect URL:   " . $session->url . "\n";    // Stripe's hosted, secure page
    
    // In a real app you redirect the browser to that URL:
    //   header("Location: " . $session->url, true, 303);
    //   exit;
    ?>
    Output
    Session created: cs_test_a1B2c3D4e5F6
    Redirect URL:   https://checkout.stripe.com/c/pay/cs_test_a1B2c3...
    Real PHP using the Stripe SDK. It calls Stripe's API with your sk_test_… key, so run it in your own project on a web server. The session id and URL will differ each run.

    In a live app the last step is a redirect — header("Location: " . $session->url, true, 303); exit; — sending the browser to Stripe's secure page where the customer enters their card. PayPal works the same way conceptually: you create an order server-side via its REST SDK, redirect the buyer to PayPal to approve, then capture the order on return. The provider differs; the "never touch the card, confirm server-side" shape does not.

    3️⃣ Webhooks: Fulfil Only When Money Clears

    Never treat the success_url redirect as proof of payment — the customer can close the tab or lose signal. A webhook is a server-to-server message Stripe POSTs to a URL you own after the payment settles, and it's the authoritative signal to fulfil the order (grant access, ship, email a receipt). Because anyone on the internet can POST to that URL, you must verify the signature: Webhook::constructEvent() checks the Stripe-Signature header against your webhook secret and throws if it's forged.

    Verified webhook endpoint (webhook.php)
    <?php
    // Webhook endpoint (webhook.php). Stripe POSTs here server-to-server AFTER the
    // payment, even if the customer closes the browser. THIS is where you fulfil.
    require_once "vendor/autoload.php";
    
    use Stripe\\Webhook;
    use Stripe\\Exception\\SignatureVerificationException;
    
    $payload   = file_get_contents("php://input");          // raw request body
    $signature = $_SERVER["HTTP_STRIPE_SIGNATURE"] ?? "";   // the Stripe-Signature header
    
    try {
        // Verify the signature so you KNOW the event really came from Stripe.
        $event = Webhook::constructEvent(
            $payload,
            $signature,
            getenv("STRIPE_WEBHOOK_SECRET")
        );
    } catch (SignatureVerificationException $e) {
        http_response_code(400);          // 400 = reject a forged or tampered request
        exit("Invalid signature");
    }
    
    // Fulfil ONLY when Stripe says the money cleared.
    if ($event->type === "checkout.session.completed") {
        $session = $event->data->object;
        echo "Paid! Fulfilling order for " . $session->customer_email . "\n";
        // markOrderPaid($session->id);  sendConfirmationEmail($session->customer_email);
    }
    
    http_response_code(200);   // 200 tells Stripe "got it" so it stops retrying
    ?>
    Output
    Paid! Fulfilling order for alice@example.com
    Real PHP. This endpoint runs on your public web server and receives signed events from Stripe. Test it locally with the Stripe CLI: stripe listen --forward-to localhost:8000/webhook.php.

    Return 200 quickly so Stripe stops retrying; if a webhook handler is slow, kick the heavy work to a queued job and return 200 right away. And store only the minimum: the Stripe ids (cs_…, pi_…), an order status, and maybe the last 4 digits Stripe gives you — never the full card number, CVC, or expiry.

    4️⃣ Idempotency & Refunds

    Networks retry. Without protection, a retried "create charge" or "refund" could run twice and bill the customer twice. An idempotency key — any unique string you attach to the request — fixes this: send the same key again and Stripe performs the action once and returns the original result. Refunds are a normal server-side call; refund the full amount or a partial one by passing amount in cents.

    Refund with an idempotency key
    <?php
    // Refund a charge from the server. The "idempotency key" makes a retry safe:
    // send the same key twice and Stripe refunds ONCE, not twice.
    require_once "vendor/autoload.php";
    
    use Stripe\\StripeClient;
    
    $stripe = new StripeClient(getenv("STRIPE_SECRET_KEY"));
    
    $refund = $stripe->refunds->create(
        ["payment_intent" => "pi_3Nxyz...", "amount" => 4900], // full $49.00 refund
        ["idempotency_key" => "refund-order-1234"]             // safe to retry
    );
    
    echo "Refund status: " . $refund->status . "\n";   // succeeded / pending / failed
    echo "Refunded:      $" . ($refund->amount / 100) . "\n";
    ?>
    Output
    Refund status: succeeded
    Refunded:      $49
    Real PHP. Swap in a real payment_intent id from a test charge, then run it in your project. Re-running with the same idempotency key won't refund twice.

    5️⃣ Your Turn

    Now you try. The first script is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel. This one drills the rule that mattered most: set the price on the server, in cents.

    🎯 Your turn: set the price (server-side, in cents)
    <?php
    // 🎯 YOUR TURN — set the price on the SERVER (never trust the browser).
    // Stripe amounts are in CENTS, so multiply whole dollars by 100.
    require_once "vendor/autoload.php";
    use Stripe\\StripeClient;
    
    $stripe = new StripeClient(getenv("STRIPE_SECRET_KEY"));
    
    $priceInCents = ___;   // 👉 a $25.00 item, in cents (25 * 100)
    
    $session = $stripe->checkout->sessions->create([
        "mode" => "payment",
        "line_items" => [[
            "quantity" => 1,
            "price_data" => [
                "currency"     => "usd",
                "unit_amount"  => $priceInCents,
                "product_data" => ["name" => "E-book"],
            ],
        ]],
        "success_url" => "https://myapp.com/success",
        "cancel_url"  => "https://myapp.com/cancel",
    ]);
    
    echo "Charging " . ($priceInCents / 100) . " dollars\n";
    
    // ✅ Expected output:
    //    Charging 25 dollars
    ?>
    Output
    Charging 25 dollars
    Replace the ___ with the price of a $25.00 item in cents (that's 25 * 100). The echo should print "Charging 25 dollars".

    One more. Here the webhook must reject a forged request with the right HTTP status. Fill in the blank so a bad signature returns a "Bad Request".

    🎯 Your turn: reject an unverified webhook
    <?php
    // 🎯 YOUR TURN — finish the webhook so it ONLY fulfils a verified event.
    // Fill the blank so a bad signature is rejected with a 400 status.
    require_once "vendor/autoload.php";
    use Stripe\\Webhook;
    use Stripe\\Exception\\SignatureVerificationException;
    
    $payload   = file_get_contents("php://input");
    $signature = $_SERVER["HTTP_STRIPE_SIGNATURE"] ?? "";
    
    try {
        $event = Webhook::constructEvent($payload, $signature, getenv("STRIPE_WEBHOOK_SECRET"));
    } catch (SignatureVerificationException $e) {
        http_response_code(___);        // 👉 the HTTP code that means "Bad Request"
        exit("Invalid signature");
    }
    
    echo "Verified event: " . $event->type . "\n";
    http_response_code(200);
    
    // ✅ Expected output (for a genuine event):
    //    Verified event: checkout.session.completed
    ?>
    Output
    Verified event: checkout.session.completed
    Replace the ___ with the HTTP status code that means "Bad Request" (it's 400). A genuine event then prints its type.

    Common Errors (and the fix)

    • You trusted an amount sent by the browser — a customer edited the price to 1 cent and you charged it. Never read the charge amount from the request. Set unit_amount in server code, ideally looked up from your database by product id.
    • "Invalid signature" / fulfilment never happens — you passed the wrong value to constructEvent(). It needs the raw body from file_get_contents("php://input") (not $_POST) and your webhook secret (whsec_…), which is different from your API secret key.
    • Your secret key leaked into git or the browser — never hard-code sk_live_…. Load it with getenv() from a .env that's git-ignored. Only the publishable key (pk_…) belongs in front-end code. If a secret key leaks, roll it in the Stripe dashboard immediately.
    • The customer got charged (or refunded) twice — a timeout made your code retry a money-moving call with no idempotency_key. Attach a stable key per logical action, and treat each webhook event id as "process once" so a Stripe retry can't double-apply it.

    Pro Tips

    • 💡 Test mode mirrors live exactly. Build with sk_test_… and card 4242 4242 4242 4242; going live is just swapping in sk_live_… — the code is identical.
    • 💡 The webhook is the source of truth. Put fulfilment in the checkout.session.completed handler, not in your success_url page.
    • 💡 Store less. Keep Stripe ids and a status — let Stripe hold the sensitive card data so a breach of your database leaks nothing payable.

    📋 Quick Reference — Stripe Payments

    ConceptExample / KeyWhat It Does
    Checkout Session$stripe->checkout->sessions->create()Server-built hosted pay page
    PaymentIntentpi_3Nxyz...Tracks one payment's lifecycle
    unit_amount4900Price in cents ($49.00)
    Webhook verifyWebhook::constructEvent()Proves the event is from Stripe
    Idempotency key["idempotency_key" => "..."]Makes a retried call run once
    Refund$stripe->refunds->create()Return money, full or partial
    Test vs livesk_test_… / sk_live_…Sandbox vs real money

    Frequently Asked Questions

    Q: What is the difference between a PaymentIntent and a Checkout Session?

    A PaymentIntent is Stripe's core object that tracks a single payment through its lifecycle (requires action, processing, succeeded). A Checkout Session is a higher-level wrapper that builds Stripe's entire hosted payment page for you and creates a PaymentIntent behind the scenes. Use Checkout Sessions when you want the fastest, most secure integration with the least code; drop down to a PaymentIntent with Stripe.js Elements when you need a fully custom, embedded card form.

    Q: Why can't I just charge the amount the browser sends me?

    Because the browser is fully under the customer's control — anyone can open dev tools and change a hidden field from $49.00 to $0.01 before it reaches you. Always set prices on the server (ideally look them up from your database by product ID). The amount the customer pays must come from code Stripe and your server control, never from the client.

    Q: Do I need webhooks if I already redirect to a success_url?

    Yes. The success_url redirect is best-effort UX, not a guarantee — the customer can close the tab, lose their connection, or the payment can complete a few seconds later. The checkout.session.completed (or payment_intent.succeeded) webhook is the authoritative, reliable signal that the money cleared, so fulfilment (granting access, shipping, emailing a receipt) belongs in the webhook handler.

    Q: What is an idempotency key and when do I need one?

    An idempotency key is a unique string you attach to a write request (creating a charge, a refund) so that if the same request is sent twice — because of a timeout or retry — Stripe performs the action only once and returns the original result the second time. Use one on any request that moves money so a flaky network can't double-charge or double-refund a customer.

    Q: How do I test payments without real money?

    Use Stripe's test mode. Your dashboard gives you a separate pair of test keys (sk_test_... / pk_test_...) that hit a sandbox where no real money moves. Pay with test card 4242 4242 4242 4242, any future expiry, and any CVC. When you are ready to go live, swap in your live keys (sk_live_... / pk_live_...) — the code stays identical.

    Mini-Challenge: Safe Partial Refund

    No code is filled in this time — just a brief and an outline. Write it yourself, run it in your own project against a test charge, then check your result against the expected output in the comments. This is the write-run-check loop you'll use on every real integration.

    🎯 Mini-Challenge: refund half a charge, idempotently
    <?php
    // 🎯 MINI-CHALLENGE: Issue a partial refund, safely.
    // No code is filled in — work from the steps, then run it in your project.
    //
    // 1. require "vendor/autoload.php" and  use Stripe\\StripeClient;
    // 2. Create a StripeClient with getenv("STRIPE_SECRET_KEY").
    // 3. Call $stripe->refunds->create([...], [...]) to refund HALF of a
    //    $80.00 charge — so "amount" => 4000  (remember: cents).
    // 4. Pass an "idempotency_key" (e.g. "refund-order-9001") in the SECOND array
    //    so a retry can't double-refund.
    // 5. echo the refund status and the dollar amount ($refund->amount / 100).
    //
    // ✅ Expected output (example):
    //    Refund status: succeeded
    //    Refunded $40
    
    // your code here
    ?>
    Refund $40.00 of an $80.00 charge with an idempotency_key, then echo the status and amount. Re-running with the same key must not refund twice.

    🎉 Lesson Complete!

    • ✅ Card data goes browser → Stripe, never to your server — that's what keeps you out of full PCI scope
    • ✅ A Checkout Session wraps a PaymentIntent and builds Stripe's hosted pay page for you
    • ✅ Set the price on the server, in cents — never trust an amount from the browser
    • ✅ Fulfil from a webhook and always verify its signature with constructEvent()
    • ✅ Use an idempotency key on charges and refunds so retries can't double-apply
    • ✅ Build on sk_test_… keys, store only Stripe ids + status, and keep secret keys out of git
    • Next lesson: E-Commerce Logic — turn paid sessions into carts, orders, and invoices

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service