Stripe Checkout Integration in PHP and Node.js (Complete Guide)
Stripe is the global payment gateway of choice for SaaS, subscriptions, and international billing. This guide walks through integrating Stripe Checkout — server-side session creation, hosted payment page, webhook signature verification, and the go-live checklist — with working code for both PHP and Node.js.
Stripe vs. Razorpay — quick decision
Both are excellent. The right choice depends on your audience:
- Razorpay — India-first. Best UPI support. INR is native. Settlement to Indian bank accounts. See our Razorpay integration guide.
- Stripe — Global. Best for international cards, subscriptions, marketplaces. INR is supported for Indian-registered businesses, but Stripe's global reach is the main draw.
Many Indian SaaS with international customers use both — Razorpay for Indian customers (to leverage UPI's low fees), Stripe for everyone else.
The Stripe Checkout flow
1. User clicks "Subscribe" / "Buy Now" on your site
2. Your server calls Stripe — creates a Checkout Session
3. Stripe returns a URL to a hosted payment page
4. Your server redirects the user to that URL
5. User completes payment on Stripe's hosted page
6. Stripe redirects back to your success URL with ?session_id=...
7. Your server verifies by calling Stripe API for that session_id
8. Your server marks the order paid, provisions service, emails receipt
9. (Redundancy) Stripe fires webhooks — you verify signature + mark paid (idempotent)Steps 7 and 9 are both critical. The browser URL can be tampered; the Stripe API response and webhook signatures cannot.
Prerequisites
- Stripe account (stripe.com). Test mode is free immediately; live mode requires business verification.
- Dashboard → Developers → API keys. Copy:
- Publishable key (starts pk_test_ / pk_live_)
- Secret key (starts sk_test_ / sk_live_) — keep private
- In
.env:
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=See Environment Variables & Secrets Management for securing these.
Test cards:
- Success:
4242 4242 4242 4242, any future expiry, any CVV, any ZIP - Insufficient funds:
4000 0000 0000 9995 - Generic decline:
4000 0000 0000 0002
PHP implementation
Install
composer require stripe/stripe-phpCreate a Checkout Session
<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);
$session = \Stripe\Checkout\Session::create([
'mode' => 'payment',
'line_items' => [[
'price_data' => [
'currency' => 'inr',
'product_data' => [
'name' => 'Domain Registration (.com, 1 year)',
],
'unit_amount' => 99900, // amount in paise — Rs 999
],
'quantity' => 1,
]],
'success_url' => 'https://yourdomain.com/success?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => 'https://yourdomain.com/cancel',
'customer_email' => '[email protected]',
'metadata' => [
'internal_order_id' => '12345',
],
]);
// Redirect to the hosted page
header("Location: " . $session->url);
exit;Critical fields:
unit_amountis in the smallest currency unit — paise for INR, cents for USD. For Rs 999, it's 99900. Getting this wrong is the most common Stripe bug.success_urlmust include{CHECKOUT_SESSION_ID}placeholder — Stripe fills it in when redirectingmetadatais the only way to pass arbitrary data (e.g., your internal order ID) through the Stripe flow back to yourself
Verify and fulfil the order on success
Your success URL handler:
<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);
$sessionId = $_GET['session_id'] ?? '';
if (!$sessionId) { http_response_code(400); exit('Missing session_id'); }
// Fetch the session from Stripe — never trust the URL param alone
$session = \Stripe\Checkout\Session::retrieve($sessionId);
if ($session->payment_status !== 'paid') {
echo "Payment not completed. Please retry.";
exit;
}
// Payment is confirmed. Fulfil the order.
$internalOrderId = $session->metadata->internal_order_id ?? null;
$paymentIntentId = $session->payment_intent;
markOrderPaid($internalOrderId, $paymentIntentId);
echo "Thank you! Your order is confirmed.";Do not mark the order paid purely from $_GET['session_id'] without calling Session::retrieve() — a user could tamper the URL with any session ID. The retrieve call authenticates with your secret key and returns the real payment state.
Node.js implementation
Install
npm install stripeCreate session
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post('/create-checkout-session', async (req, res) => {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'inr',
product_data: { name: 'Domain Registration (.com, 1 year)' },
unit_amount: 99900,
},
quantity: 1,
}],
success_url: 'https://yourdomain.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://yourdomain.com/cancel',
customer_email: req.user.email,
metadata: { internal_order_id: '12345' },
});
res.redirect(303, session.url);
});Verify on success
app.get('/success', async (req, res) => {
const session = await stripe.checkout.sessions.retrieve(req.query.session_id);
if (session.payment_status !== 'paid') {
return res.send('Payment not completed.');
}
await markOrderPaid(session.metadata.internal_order_id, session.payment_intent);
res.send('Thank you!');
});Webhooks — the essential redundancy layer
Users close browsers. Connections drop. The success URL callback fails to reach you — but Stripe captured the payment. Without webhooks, that order is stuck "pending" forever.
Set up the webhook endpoint
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- URL:
https://yourdomain.com/webhooks/stripe(HTTPS required) - Events to listen for:
- checkout.session.completed — for Checkout payments
- payment_intent.succeeded — for direct Payment Intent integrations
- payment_intent.payment_failed
- charge.refunded
- invoice.paid, invoice.payment_failed — if using subscriptions
- Click Add endpoint
- On the endpoint's page, reveal "Signing secret" — starts with
whsec_ - Save this to
.envasSTRIPE_WEBHOOK_SECRET
PHP webhook handler
<?php
require __DIR__ . '/vendor/autoload.php';
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = $_ENV['STRIPE_WEBHOOK_SECRET'];
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit('Invalid signature');
}
// Idempotency — Stripe may retry
if (alreadyProcessed($event->id)) {
http_response_code(200);
exit('ok');
}
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
if ($session->payment_status === 'paid') {
markOrderPaid(
$session->metadata->internal_order_id ?? null,
$session->payment_intent
);
}
break;
case 'payment_intent.payment_failed':
$pi = $event->data->object;
markPaymentFailed($pi->id, $pi->last_payment_error->message ?? 'unknown');
break;
case 'charge.refunded':
$charge = $event->data->object;
markRefund($charge->payment_intent, $charge->amount_refunded);
break;
}
markEventProcessed($event->id);
http_response_code(200);
echo 'ok';Node.js webhook handler
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// CRITICAL: use raw body parser for the webhook route only.
// Stripe signatures are computed over the exact bytes sent.
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (await alreadyProcessed(event.id)) {
return res.status(200).send('ok');
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
if (session.payment_status === 'paid') {
await markOrderPaid(session.metadata.internal_order_id, session.payment_intent);
}
break;
case 'payment_intent.payment_failed':
await markPaymentFailed(event.data.object.id);
break;
case 'charge.refunded':
await markRefund(event.data.object.payment_intent, event.data.object.amount_refunded);
break;
}
await markEventProcessed(event.id);
res.status(200).send('ok');
}
);The #1 Stripe integration bug in Node.js: using express.json() middleware globally. It parses the request body into a JavaScript object — but Stripe's signature is computed over the exact raw bytes. Once Express has re-stringified the JSON, the signature never matches.
Fix: either mount express.raw({ type: 'application/json' }) specifically on the webhook route (as shown), or put your webhook route BEFORE the global express.json() line.
Subscriptions
Checkout Sessions support recurring billing too. Minor tweaks:
$session = \Stripe\Checkout\Session::create([
'mode' => 'subscription', // was 'payment'
'line_items' => [[
'price' => 'price_1ABC...', // Price ID from Stripe Dashboard, not price_data
'quantity' => 1,
]],
'success_url' => '...',
'cancel_url' => '...',
'customer_email' => '[email protected]',
]);Create the Price in Stripe Dashboard → Products → New → Recurring. You get a Price ID (starts price_).
Webhooks to handle for subscriptions:
checkout.session.completed— user completed the first checkoutcustomer.subscription.created— subscription is activeinvoice.paid— a monthly / yearly charge succeededinvoice.payment_failed— a renewal failed; typically notify the user + retry logiccustomer.subscription.deleted— subscription was cancelled (by user or by you)
For grace-period patterns (don't immediately cut off access on first failed renewal), listen to invoice.payment_failed multiple times before downgrading.
Refunds
Issue a refund via API:
$refund = \Stripe\Refund::create([
'payment_intent' => 'pi_xxx',
'amount' => 50000, // partial refund; omit for full
'reason' => 'requested_by_customer',
'metadata' => ['note' => 'Customer change of mind'],
]);Listen for charge.refunded webhook to confirm the refund posted.
Going-live checklist
Before flipping to live mode:
- Stripe account activated (business details, bank account, tax info)
- Generate live-mode keys
- Update
.envon production: replacesk_test_/pk_test_withsk_live_/pk_live_ - Create a new webhook endpoint in live mode (separate signing secret)
- Update
STRIPE_WEBHOOK_SECRETto the live signing secret - Test a real ₹1 transaction end-to-end
- Verify webhook delivery in Dashboard → Webhooks → event log
- Set up webhook event monitoring (Stripe emails on repeated failures, but also monitor yourself)
- Ensure all amount calculations happen server-side — never trust amount from the client
Common pitfalls
- Amount in major units instead of minor units.
500for Rs 5, not Rs 500. Always multiply by 100. - Missing `{CHECKOUT_SESSION_ID}` in success_url. Without it, you have no way to retrieve the session on return.
- Not verifying signature on the success URL. Trusting
$_GET['session_id']opens you to forgery. AlwaysSession::retrieve(). - Express `express.json()` swallowing the webhook raw body. Signature verification fails forever. Use
express.raw()on the webhook route only. - Missing webhook idempotency. Stripe retries events. Same event arriving twice = order credited twice. Check
event.idagainst aprocessed_eventstable. - Not handling `invoice.payment_failed` for subscriptions. Users get cut off unfairly on transient card issues. Implement smart retry + grace periods.
- Testing with live keys accidentally. Check key prefixes before every deploy.
- Hardcoding success URL with HTTP. Stripe requires HTTPS for webhooks; success URLs should also use HTTPS to prevent redirect leakage.
Frequently asked questions
What fees does Stripe charge in India?
International cards: 3.5% + fixed. Indian cards (domestic): 2% + GST. Check Stripe's India pricing page for current rates — they change occasionally.
Can I accept UPI through Stripe?
Stripe India supports UPI through partners, but Razorpay has much better native UPI support in India. For UPI-heavy markets, Razorpay is the better choice. Use Stripe for cards and international.
How long until settlements reach my bank account?
Stripe India defaults to T+3 for domestic cards, T+7 for international. Faster settlement is available after account history is established.
Can I run both Stripe test and live modes simultaneously?
Yes. Use different API keys. Typically test mode on your staging environment, live mode on production. Never mix.
What happens if a webhook delivery fails?
Stripe retries for up to 3 days with exponential backoff. If all retries fail, Stripe marks the endpoint with repeated failures and emails you. Persistent failure will auto-disable the endpoint — re-enable in the dashboard after fixing.
Do I need PCI DSS compliance?
Using Stripe Checkout (hosted) — you're PCI SAQ A compliant, the lightest level. Using Stripe Elements (embedded in your site but Stripe-hosted fields) — also SAQ A. Only if you handle raw card data yourself do you need SAQ D, which is much heavier. Use Checkout unless you have a very specific reason not to.
Can I customise the Checkout page appearance?
Limited — colours, logo, some text. Set these in Stripe Dashboard → Settings → Branding. For full control over the payment form, use Stripe Elements in your own page.
What's the difference between Checkout Session and Payment Intent?
Checkout Session — Stripe hosts the full payment form; you just redirect. Payment Intent — you host your own form, submit card details directly to Stripe with Elements. Checkout is easier; Payment Intent is more customisable.
Questions on your specific integration? [email protected]. We help hosting customers get webhooks routed correctly and HTTPS set up (Stripe requires HTTPS on all webhook URLs).