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.
Part of the free Php course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
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.
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.
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.
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.
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".
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
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
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.
🎉 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
Practice quiz
In a Stripe integration, where must the raw card number be handled?
- On your PHP server, validated before charging
- Stored encrypted in your database
- Browser to Stripe directly — it must never reach your server
- Sent to Stripe by your webhook
Answer: Browser to Stripe directly — it must never reach your server. Card data goes straight from the browser to Stripe (via Stripe.js/Elements or hosted Checkout); keeping it off your server is what stays out of full PCI scope.
Why must the charge amount be set on the server, not read from the browser?
- The browser is under the customer's control and the amount can be tampered with
- The browser cannot send numbers
- Stripe rejects amounts from the browser
- It is faster to compute on the server
Answer: The browser is under the customer's control and the amount can be tampered with. Anyone can edit a hidden field from $49.00 to $0.01, so the price must come from server code, ideally looked up by product id.
In Stripe, the unit_amount 4900 for USD represents what price?
- $4,900.00
- $490.00
- $4.90
- $49.00
Answer: $49.00. Stripe amounts are in the smallest currency unit — cents for USD — so 4900 means $49.00.
What is the authoritative signal that a payment actually cleared?
- The customer reaching the success_url page
- The checkout.session.completed webhook from Stripe
- The browser console showing 200 OK
- The session being created
Answer: The checkout.session.completed webhook from Stripe. The success_url redirect is best-effort UX; the webhook is the reliable, authoritative signal that the money cleared, so fulfilment belongs there.
Which function verifies that a webhook event genuinely came from Stripe?
- Webhook::constructEvent()
- json_decode()
- hash_equals()
- curl_exec()
Answer: Webhook::constructEvent(). Webhook::constructEvent() checks the Stripe-Signature header against your webhook secret and throws if the request was forged.
What does an idempotency key do on a money-moving request?
- Encrypts the request body
- Speeds up the API call
- Ensures a retried request runs the action only once and returns the original result
- Authenticates the customer
Answer: Ensures a retried request runs the action only once and returns the original result. Send the same idempotency key twice and Stripe performs the action once, so a flaky network can't double-charge or double-refund.
Which HTTP status should a webhook return for a forged/invalid signature?
- 200
- 400
- 301
- 500
Answer: 400. Reject a forged or tampered webhook with http_response_code(400) — Bad Request — and do not fulfil the order.
Why return 200 quickly from a webhook handler?
- So the customer sees the success page
- It is required to issue refunds
- To verify the signature
- So Stripe stops retrying the event
Answer: So Stripe stops retrying the event. Returning 200 tells Stripe the event was received so it stops retrying; push slow work to a queued job and return 200 right away.
Which key belongs ONLY on the server and must never reach the browser or git?
- The publishable key (pk_...)
- The secret key (sk_...)
- The session id (cs_...)
- The PaymentIntent id (pi_...)
Answer: The secret key (sk_...). Load sk_... from a git-ignored env file with getenv(); only the publishable pk_... key belongs in front-end code.
What is the relationship between a Checkout Session and a PaymentIntent?
- They are unrelated objects
- A PaymentIntent wraps many Checkout Sessions
- A Checkout Session is a higher-level wrapper that builds the hosted page and creates a PaymentIntent for you
- A Checkout Session replaces refunds
Answer: A Checkout Session is a higher-level wrapper that builds the hosted page and creates a PaymentIntent for you. A Checkout Session builds Stripe's hosted pay page and creates a PaymentIntent (which tracks one payment's lifecycle) behind the scenes.
Continue this course
- Previous: Environment Management
- Next: E-Commerce Logic