Why Payment Integration Deserves Extra Care
Of all the features covered in this series, payment processing has the least room for "close enough." A bug in a blog post is embarrassing; a bug in checkout logic loses money, charges customers incorrectly, or creates compliance problems. The good news is that modern payment processors like Stripe have absorbed almost all of the genuinely hard parts — PCI compliance, card storage, fraud detection — leaving the integration work itself comparatively contained, provided it is done in the way Stripe actually intends.
Never Handle Raw Card Numbers
The single most important rule: your server should never receive a raw card number at all. Stripe.js, running in the customer's browser, collects card details and exchanges them directly with Stripe for a token (a PaymentMethod) before your server ever sees anything related to payment.
const {paymentMethod, error} = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
// send paymentMethod.id to your server, never the raw card numberThis single architectural decision is what keeps your application out of the strictest tiers of PCI compliance scope — card data never touches your server, so your server never needs to be audited as if it stores card data.
Creating a Payment Intent
On the server, a Payment Intent represents the actual charge attempt, created with the amount and currency, then confirmed using the PaymentMethod token from the client:
$intent = \Stripe\PaymentIntent::create([
'amount' => $order->totalInCents(),
'currency' => 'usd',
'payment_method' => $request->input('payment_method_id'),
'confirmation_method' => 'manual',
'confirm' => true,
]);Always calculate the charge amount server-side from your own order data — never trust an amount value sent from the client, since a tampered request could otherwise charge an arbitrary, attacker-chosen amount instead of the real order total.
Webhooks: The Part Beginners Skip and Regret
A payment can succeed, fail, or change state (a delayed bank transfer, a disputed charge, a subscription renewal) at moments that have nothing to do with your customer being on your website at the time. Relying solely on the client-side success callback to mark an order as paid misses all of these cases. Stripe webhooks notify your server directly when payment events happen, and a production integration must listen for them:
$event = \Stripe\Webhook::constructEvent(
$payload, $sigHeader, config('services.stripe.webhook_secret')
);
if ($event->type === 'payment_intent.succeeded') {
$order = Order::where('payment_intent_id', $event->data->object->id)->first();
$order->markAsPaid();
}Verifying the webhook signature, as shown above, is not optional — without it, anyone who discovers your webhook URL could send fake "payment succeeded" events and get goods marked as paid without ever actually paying.
Idempotency: Avoiding Double Charges
A user double-clicking "pay now," or a flaky connection causing the client to retry a request automatically, should never result in a customer being charged twice. Stripe supports idempotency keys for exactly this purpose — a unique key per logical payment attempt, which Stripe uses to recognize and safely deduplicate a retried request.
$intent = \Stripe\PaymentIntent::create([
'amount' => $amount, 'currency' => 'usd', 'payment_method' => $pmId, 'confirm' => true,
], ['idempotency_key' => $order->idempotencyKey()]);Testing Without Real Money
Stripe provides a full set of test card numbers that simulate specific outcomes — a successful charge, a declined card, a card requiring 3D Secure authentication — letting you build and test every code path your error handling needs to cover without ever touching real payment rails. Skipping deliberate testing of the failure paths (declined cards, expired cards, authentication failures) is how checkout flows ship with broken or confusing error messages that only surface once real customers hit them.
Subscriptions and Recurring Billing
For subscription billing, Stripe handles the recurring charge logic itself once a subscription is created — your application does not need to build its own cron-based "charge everyone due today" logic, which removes an entire category of the scheduling and idempotency risks covered elsewhere in this series. Your application's job is to react to subscription webhook events (renewed, payment failed, canceled) and keep its own records in sync with what Stripe reports as the source of truth.
Closing Thought
Stripe (and processors like it) deliberately push almost all of the dangerous complexity — storing card data, PCI scope, fraud detection — out of your application entirely. What remains is still real engineering work: correct server-side amount calculation, properly verified webhooks as the authoritative source of payment state, idempotent charge attempts, and genuine testing of failure paths. Skipping any of these is how a checkout flow that looks fine in a demo becomes a source of double charges, missed orders, or fraud in production.
Need a payment integration built correctly the first time? We can handle it end to end.
Handling 3D Secure and Authentication Challenges
European regulation (PSD2) and increasingly card networks worldwide require Strong Customer Authentication for many transactions — the familiar "verify with your bank app" or SMS code step. Stripe handles the actual challenge flow, but your integration needs to handle the intermediate state correctly: a Payment Intent can come back requiring additional action rather than immediately succeeding or failing.
if ($intent->status === 'requires_action') {
return response()->json([
'requires_action' => true,
'payment_intent_client_secret' => $intent->client_secret,
]);
}
// client-side: stripe.handleCardAction(clientSecret) prompts the authentication challengeSkipping this state — treating anything other than immediate success as a failure — breaks checkout for a meaningful share of European card transactions, since 3D Secure challenges are common there and only growing more common elsewhere as fraud regulations tighten globally.
Refunds and Partial Refunds
A complete payment integration needs a considered refund path, not just a forward-only charge flow. Stripe supports full and partial refunds against an existing charge, and your application needs to update its own order state to reflect whichever happened, since "refunded" and "partially refunded" usually need different handling in reporting, inventory, and customer communication:
$refund = \Stripe\Refund::create(['payment_intent' => $intent->id, 'amount' => $partialAmountInCents]);
$order->recordRefund($refund->amount);Handling Disputes and Chargebacks
A chargeback — a customer disputing a charge through their bank rather than through you directly — arrives as a webhook event, not as a request your application initiates. Failing to listen for dispute-related webhooks means finding out about a chargeback only when Stripe deducts the disputed amount (plus a fee) from your next payout, with no advance notice and no chance to respond with evidence before the dispute deadline passes.
Saving Cards for Future Use
Many businesses need to charge a customer again later — a subscription renewal, a saved card for faster future checkout — without asking them to re-enter their card details. Stripe supports this through Setup Intents and saved PaymentMethods attached to a Customer object, again without your server ever storing or even briefly handling the actual card number:
$customer = \Stripe\Customer::create(['email' => $user->email]);
$setupIntent = \Stripe\SetupIntent::create(['customer' => $customer->id]);
// client confirms the setup intent with card details, attaching the resulting PaymentMethod to the customer
$futureCharge = \Stripe\PaymentIntent::create([
'amount' => $amount, 'currency' => 'usd',
'customer' => $customer->id, 'payment_method' => $savedPaymentMethodId,
'off_session' => true, 'confirm' => true,
]);The off_session flag tells Stripe this charge is happening without the customer actively present in a checkout flow, which affects fraud scoring and whether additional authentication might be required — an important distinction your integration needs to handle, since an off-session charge that unexpectedly requires authentication needs a notification path back to the customer rather than silently failing.
Reconciling Payments With Orders: Avoiding Drift
Over time, payment records in Stripe and order records in your own database can drift apart — a webhook that failed to deliver, a manual refund issued directly in the Stripe dashboard that your application never heard about. A periodic reconciliation job, comparing Stripe's record of recent charges against your own order table and flagging mismatches, catches this drift before it becomes a customer-facing or accounting problem; treating webhooks as the only mechanism keeping the two systems in sync, with no periodic safety-net check, leaves a gap whenever webhook delivery itself has a bad day.
PCI Compliance: What Actually Falls on You
Using Stripe.js and never touching raw card data places most businesses in the simplest PCI compliance tier (SAQ A), requiring mainly a signed self-assessment rather than a full security audit. This simplicity is conditional, though — it depends on never modifying the checkout flow in a way that touches card data server-side, and keeping the Stripe.js script loaded directly from Stripe rather than self-hosted or proxied, since the compliance simplification specifically relies on Stripe's own infrastructure handling the sensitive parts end to end.
Case Study: A Webhook That Silently Stopped Arriving
An online course platform relied entirely on Stripe webhooks to mark enrollments as paid. During a routine server migration, the webhook endpoint URL was not updated in the Stripe dashboard, and for nine days, every successful payment event from Stripe had nowhere valid to deliver to. Customers paid successfully, Stripe showed the charges as completed, but the platform's own database never learned about any of it — new students could not access courses they had just paid for, generating a wave of support tickets that initially looked like a totally unrelated bug in the enrollment logic. The eventual fix was simple (updating the endpoint URL), but the deeper lesson was the lack of a reconciliation safety net: had the platform run even a daily job comparing Stripe's list of recent successful charges against its own enrollment records, the gap would have surfaced within a day instead of nine, and the cause would have been obvious immediately rather than requiring a confused multi-day investigation.
A Glossary for This Topic
Payment Intent: Stripe's object representing a single attempt to collect payment, tracking its status through the various stages of being created, confirmed, and either succeeded or failed. Idempotency key: a unique identifier sent with a request so that retrying it does not duplicate the underlying action. Webhook: an HTTP request Stripe sends to your server when a payment-related event occurs, used to keep your application in sync with events that may not originate from a request your server initiated. 3D Secure: an additional authentication step (often a bank app confirmation) required for some card transactions under stronger fraud regulations.
Frequently Asked Questions
Do I need to store any payment information in my own database? You should store Stripe's references (a PaymentIntent ID, a Customer ID) and your own order/transaction status, but never raw card details — those should never reach your server in the first place if integrated correctly via Stripe.js.
What happens if my server is down when a webhook is sent? Stripe retries failed webhook deliveries on a backoff schedule for a period of time, but a server that is down for an extended outage can still miss events permanently once retries are exhausted, which is exactly why a periodic reconciliation check against Stripe's own records is valuable as a safety net rather than relying on webhook delivery alone.
Can I test the full payment flow without a live Stripe account? Yes — Stripe's test mode and test card numbers let you exercise the entire flow, including webhooks (using the Stripe CLI to forward test events to a local server), without any real account setup or real money involved.
Step-by-Step: Building a Complete Checkout Integration
Step one: collect card details client-side using Stripe Elements, never building a custom card input form that touches raw card data yourself. Step two: create a Payment Intent server-side with an amount calculated from your own authoritative order data, never trusting an amount value from the client. Step three: confirm the intent client-side, handling the requires_action state for 3D Secure challenges as covered earlier. Step four: treat the client-side success callback as a UX signal only ("show a thank-you page"), never as the source of truth for marking an order paid. Step five: mark the order paid exclusively from a verified webhook event, ensuring the order state reflects the real, server-confirmed payment status regardless of what happened on the client. Step six: build a reconciliation job comparing Stripe's charge records against your order table on a regular schedule, catching any gap before it becomes a customer-facing problem. Step seven: test every failure path explicitly using Stripe's test card numbers for declines, authentication failures, and processing errors, not just the success path.
A Comparison Table: Payment Integration Approaches at a Glance
Stripe Checkout (hosted page): Stripe hosts the entire payment page, minimal integration work, less control over checkout branding and flow. Stripe Elements (embedded components): card fields embedded directly in your own page, full control over surrounding checkout UI, still never touching raw card data. Custom integration against the raw Payment Intents API: maximum flexibility for complex checkout flows, most integration work and most responsibility for handling edge cases like 3D Secure correctly.
Security Considerations Checklist
Never accept a charge amount from client input — always calculate it server-side from authoritative order data. Always verify webhook signatures before trusting any event payload. Use idempotency keys on every charge-creation request to prevent double charges from retries. Store only Stripe references (IDs), never raw card numbers, in your own database. Restrict your Stripe secret key to server-side environment variables, never bundled into client-side code. Enable Stripe Radar or equivalent fraud detection rather than assuming basic card validation is sufficient protection against fraudulent transactions.
Accessibility Considerations
Checkout forms need the same accessibility care as any other critical form: labeled inputs, clear error messages associated with the specific field that failed validation, and a logical tab order through the payment fields. Stripe Elements ships with reasonable accessibility defaults, but surrounding custom checkout UI (order summary, promo code fields, submit button states) still needs the same screen-reader and keyboard-navigation testing as the rest of your application, not an assumption that Stripe's components alone make the entire checkout flow accessible.
How This Plays Out at Different Scales
A small store processing a handful of orders a day can reasonably rely on Stripe's dashboard alone for reconciliation, manually checking against orders periodically. A growing business processing hundreds of daily transactions needs the automated reconciliation job described earlier, since manual checking does not scale and silent webhook failures become statistically more likely to occur and more costly to leave undetected. A large platform processing payments on behalf of multiple sellers (a marketplace) needs to consider Stripe Connect specifically, which adds its own layer of complexity around how funds are split, held, and paid out across multiple connected accounts.
What to Do When You Inherit a Checkout Flow With No Webhooks
Inheriting a payment integration that marks orders paid purely from the client-side success callback, with no webhook handling at all, is a common state for an unmaintained or rapidly-shipped checkout flow. The first fix is not a rewrite — it is adding webhook handling as an additional, parallel safety net without removing the existing client-side flow yet, then running both in parallel and logging any case where the webhook reports success but the order was never marked paid by the client-side path (or vice versa). This surfaces the actual scale of the gap with real production data before committing to a larger refactor, and gives you evidence-backed confidence about how much revenue or how many orders were actually affected by the missing webhook coverage historically.
Final Checklist Before Going Live
Test mode fully exercised including decline and 3D Secure paths. Webhook endpoint registered and signature verification confirmed working with real test events. Idempotency keys in place on all charge-creation calls. Reconciliation job scheduled and tested against a deliberately introduced mismatch. Refund and dispute handling paths reviewed, not just the happy charge path. Live API keys stored only in server-side environment configuration, never committed to version control.
Closing Thought, Revisited
Every payment integration ages the same way: it works perfectly in testing, works perfectly during a soft launch with a handful of trusted customers, and then reveals its actual gaps once real volume and real edge cases (declined cards, disputed charges, webhook delivery hiccups) start arriving at a rate that exposes whatever corners were cut. Building the reconciliation, webhook verification, and idempotency safeguards described in this guide before that volume arrives is meaningfully cheaper than retrofitting them after a customer-facing incident has already forced the issue.
Testing Webhook Handling Locally
The Stripe CLI lets you forward real test-mode webhook events to a local development server, so webhook handling code can be exercised and debugged before deploying, rather than only discovering bugs in it after a deploy to a staging or production environment. Running stripe listen --forward-to localhost:8000/webhooks/stripe during development and triggering test events with stripe trigger payment_intent.succeeded gives you a fast local feedback loop for webhook code, the same way local testing speeds up feedback for any other part of an application instead of relying on a slower deploy-and-check cycle.
A second useful local-testing habit: deliberately simulate webhook delivery failures (a 500 response from your endpoint, a delayed response) to confirm your retry-tolerant logic behaves correctly — specifically, that a retried webhook delivery for an event your server already processed does not result in double-processing, which ties directly back to the idempotency discussion earlier in this guide.