Previous Article
Export MySQL Data to CSV or Excel in PHP (The Correct Way)
12 May 2018
Stripe's API has changed fundamentally since the token-and-charge era. If you are following a tutorial written before 2019 — including the earlier version of this article — you are implementing something that no longer works and would not be safe if it did.
This guide covers the current approach: Checkout Sessions for most cases, Payment Intents when you need control, and webhooks to confirm what actually happened.
The pattern in old tutorials collects card details in your own inputs:
<!-- NEVER DO THIS --> <input type="text" data-stripe="number"> <input type="text" data-stripe="cvc"> <script src="https://js.stripe.com/v2/"></script> <script>Stripe.card.createToken($form, handler);</script>
Three separate reasons this is unusable:
Stripe.card.createToken no longer exists.Payment Intents exist specifically to handle that authentication step. That is why the API changed.
composer require stripe/stripe-php
// Never commit these. Secret key server-side only. STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_...
The publishable key is safe in client code. The secret key grants full account access — if one is ever committed, roll it in the dashboard immediately, because bots scan public repositories for exactly this.
Stripe hosts the payment page. You get SCA handling, Apple Pay and Google Pay, localisation, and address collection without writing any of it.
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);
// Load the price from YOUR database. Never trust an amount sent by the browser.
$order = getOrder($pdo, (int) $_POST['order_id']);
$session = \Stripe\Checkout\Session::create([
'mode' => 'payment',
'line_items' => [[
'price_data' => [
'currency' => 'inr',
'unit_amount' => $order['total_paise'], // smallest currency unit
'product_data' => [
'name' => $order['product_name'],
'description' => $order['product_description'],
],
],
'quantity' => $order['quantity'],
]],
'success_url' => 'https://example.com/order/complete?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => 'https://example.com/cart',
// Your own reference, returned on the webhook.
'client_reference_id' => (string) $order['id'],
'customer_email' => $order['email'],
'metadata' => ['order_id' => (string) $order['id']],
], [
// Idempotency: a retried request returns the original session
// instead of creating a second one.
'idempotency_key' => 'checkout_' . $order['id'] . '_' . $order['updated_at'],
]);
header('Location: ' . $session->url, true, 303);
exit;
Amounts are in the smallest currency unit. ₹499.00 is 49900, not 499. Getting this wrong charges a hundredth or a hundred times the intended amount — always store money as integers and never let a float near it.
Read the price from your own database. If the amount comes from a hidden form field, anyone can change it to 1 and buy your product for a rupee. This is the most common payment integration vulnerability there is.
When you need the payment inside your own page:
// Server: create the intent
$intent = \Stripe\PaymentIntent::create([
'amount' => $order['total_paise'],
'currency' => 'inr',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => ['order_id' => (string) $order['id']],
]);
echo json_encode(['clientSecret' => $intent->client_secret]);
<script src="https://js.stripe.com/v3/"></script>
const stripe = Stripe('pk_test_...');
const { clientSecret } = await fetch('/api/create-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }),
}).then(r => r.json());
const elements = stripe.elements({ clientSecret });
elements.create('payment').mount('#payment-element');
document.querySelector('#pay-form').addEventListener('submit', async (e) => {
e.preventDefault();
const button = document.querySelector('#pay-button');
button.disabled = true;
// confirmPayment handles the 3D Secure challenge automatically when
// the issuing bank requires it.
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: 'https://example.com/order/complete' },
});
if (error) {
document.querySelector('#payment-error').textContent = error.message;
button.disabled = false;
}
// On success the browser is redirected to return_url.
});
The card details live inside Stripe's iframe. Your JavaScript never sees them, which is what keeps you in SAQ A scope.
This is the part that separates a working integration from one that quietly loses orders.
The customer's return to success_url is not proof of payment. They might close the tab before redirecting, lose connection, or simply visit that URL directly. Equally, a payment can succeed minutes later after an asynchronous method settles.
Webhooks are the source of truth:
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
// Raw body — the signature is computed over the exact bytes.
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
$_ENV['STRIPE_WEBHOOK_SECRET']
);
} catch (\Stripe\Exception\SignatureVerificationException) {
http_response_code(400);
exit('Invalid signature');
}
// Idempotency — Stripe retries, and will resend events you have already seen.
if (alreadyProcessed($pdo, $event->id)) {
http_response_code(200);
exit;
}
recordEvent($pdo, $event->id, $event->type);
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
// 'paid' matters — a session can complete while payment is still pending.
if ($session->payment_status === 'paid') {
fulfilOrder((int) $session->metadata->order_id, $session->amount_total);
}
break;
case 'payment_intent.succeeded':
fulfilOrder((int) $event->data->object->metadata->order_id,
$event->data->object->amount);
break;
case 'payment_intent.payment_failed':
notifyFailure((int) $event->data->object->metadata->order_id);
break;
case 'charge.refunded':
processRefund($event->data->object);
break;
}
// Return quickly. Stripe retries on timeout or any non-2xx.
http_response_code(200);
Always re-verify the amount against your own record before fulfilling — the same check that matters for any payment provider.
function fulfilOrder(int $orderId, int $amountPaid): void
{
global $pdo;
$order = getOrder($pdo, $orderId);
if ($order === null || $amountPaid !== $order['total_paise']) {
error_log("Amount mismatch on order {$orderId}: paid {$amountPaid}");
flagForReview($orderId);
return;
}
markPaid($pdo, $orderId);
sendConfirmation($order);
}
stripe login stripe listen --forward-to localhost:8000/webhooks/stripe # prints a whsec_... — use it as your local STRIPE_WEBHOOK_SECRET stripe trigger checkout.session.completed
Useful test cards:
| Number | Behaviour |
|---|---|
4242 4242 4242 4242 | Succeeds |
4000 0025 0000 3155 | Requires 3D Secure authentication |
4000 0000 0000 9995 | Declined — insufficient funds |
4000 0000 0000 0002 | Declined — generic |
Test the 3D Secure card specifically. An integration that only ever sees 4242 will fail the first time a real European customer's bank asks for authentication.
Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.