Stripe is a popular payment gateway for SaaS, subscriptions and international billing. This guide walks through integrating Stripe Checkout — server-side session creation, the hosted payment page, webhook signature verification and the go-live checklist — with working code for both PHP and Node.js.
Create a Checkout Session on your server, redirect the buyer to Stripe's hosted page, and confirm payment in two ways: by retrieving the session on your success URL and by handling a signed checkout.session.completed webhook. Amounts are in the smallest currency unit (paise for INR), webhooks must be idempotent, and the webhook route needs the raw request body. On Domain India shared hosting, install the PHP library on your own computer and upload vendor/, because Composer cannot run on the server.
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. Strong for international cards, subscriptions and marketplaces. Stripe's onboarding of new Indian businesses has been restricted (invite-only) since 2024, so check that you can open an account before you build on it.
Many Indian SaaS businesses with international customers use both: Razorpay for Indian customers (UPI and domestic methods), 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
- Publishable key (starts
- 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. Never commit the secret key to git or place .env inside public_html.
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-phpRun this on your own computer or in CI. On shared hosting, upload the project together with its vendor/ folder (see the Domain India section below).
Create 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' => 'Annual plan (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: 'Annual plan (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 stays unpaid in your system 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 paymentspayment_intent.succeeded— for direct Payment Intent integrationspayment_intent.payment_failedcharge.refundedinvoice.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 cut off access on the first failed renewal), let Stripe's automatic retries run and downgrade only when the subscription status changes to past_due or canceled.
For recurring charges on Indian cards, RBI e-mandate rules apply: the cardholder must approve the mandate, and some renewals need extra authentication. Expect more invoice.payment_failed events from Indian cards than from foreign ones, and send the customer a link to pay.
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 small real transaction end to end, then refund it
- 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. - Trusting the success URL. Marking an order paid from
$_GET['session_id']alone opens you to forgery. AlwaysSession::retrieve(). - Express
express.json()swallowing the webhook raw body. Signature verification fails forever. Useexpress.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_failedfor 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.
Running this on Domain India
curl_exec() works, so the Stripe PHP library can call the API over HTTPS. Composer cannot run on the server: run composer require locally and upload vendor/.curl_exec() is disabled, which the Stripe PHP library needs. Test a Checkout Session call on your account before you go live.Stripe only calls webhook URLs over HTTPS. Free SSL is included with Domain India hosting and the App Platform, so make sure the certificate for your domain is active before you add the endpoint. For the full list of blocked PHP functions, see PHP disabled functions on shared hosting.
Frequently asked questions
What fees does Stripe charge in India?
Stripe charges a percentage per transaction, with different rates for domestic and international cards, plus GST. Rates change, so check Stripe's India pricing page before you choose a gateway.
Can I accept UPI through Stripe?
UPI support through Stripe in India is limited. If most of your customers pay by UPI, an India-first gateway such as Razorpay is usually the better fit; use Stripe for cards and international buyers.
How long until settlements reach my bank account?
Stripe shows your payout schedule in the Dashboard under balance and payout settings. It depends on your country, account history and card type, so check it there rather than relying on a fixed number.
Can I run Stripe test and live modes at the same time?
Yes. They use different API keys and separate webhook endpoints. Use test keys on staging and live keys on production, and never mix them.
What happens if a webhook delivery fails?
In live mode Stripe retries a failed webhook delivery for up to three days with increasing gaps, and emails you about an endpoint that keeps failing. Make your handler idempotent so a retried event is not processed twice.
Do I need PCI DSS compliance?
With Stripe Checkout or Stripe Elements, card details go straight to Stripe, so you normally qualify for the lightest self-assessment (SAQ A). Only if you handle raw card numbers yourself do you need the much heavier SAQ D.
Can I customise the Checkout page appearance?
Partly. You can set colours, logo and some text in the Stripe Dashboard branding settings. For full control over the payment form, use Stripe Elements in your own page.
What is the difference between a Checkout Session and a Payment Intent?
With a Checkout Session, Stripe hosts the whole payment form and you only redirect to it. With a Payment Intent, you build the form in your own page with Stripe Elements. Checkout is easier; Payment Intents give more control.
Ready to take payments? Host a PHP integration on cPanel hosting, run the Node.js version on the App Platform, or get full control with a VPS. If you get stuck with HTTPS or webhook routing on your account, open a support ticket.
Run your checkout on Domain India hosting with free SSL, so Stripe can reach your HTTPS webhook endpoint.
View cPanel hosting