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
composer require stripe/stripe-php. Always use your test-mode keys (sk_test_…) while learning — no real money moves. The Output panel under each example shows what to expect.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.
<?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;
?>Session created: cs_test_a1B2c3D4e5F6
Redirect URL: https://checkout.stripe.com/c/pay/cs_test_a1B2c3...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.
<?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
?>Paid! Fulfilling order for alice@example.comstripe 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.
<?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";
?>Refund status: succeeded
Refunded: $49payment_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.
<?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
?>Charging 25 dollars___ 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".
<?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
?>Verified event: checkout.session.completed___ 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
1cent and you charged it. Never read the charge amount from the request. Setunit_amountin 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 fromfile_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 withgetenv()from a.envthat'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 card4242 4242 4242 4242; going live is just swapping insk_live_…— the code is identical. - 💡 The webhook is the source of truth. Put fulfilment in the
checkout.session.completedhandler, not in yoursuccess_urlpage. - 💡 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
| Concept | Example / Key | What It Does |
|---|---|---|
| Checkout Session | $stripe->checkout->sessions->create() | Server-built hosted pay page |
| PaymentIntent | pi_3Nxyz... | Tracks one payment's lifecycle |
| unit_amount | 4900 | Price in cents ($49.00) |
| Webhook verify | Webhook::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 live | sk_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.
<?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
?>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.