Client Area

Stripe Checkout Integration in PHP and Node.js (Complete Guide)

ByDomain India Team
10 min read22 Apr 20262 views

In this article

  • 1Stripe vs. Razorpay — quick decision
  • 2The Stripe Checkout flow
  • 3Prerequisites
  • 4PHP implementation
  • 5Install

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

bash
composer require stripe/stripe-php

Create a Checkout Session

php
<?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_amount is 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_url must include {CHECKOUT_SESSION_ID} placeholder — Stripe fills it in when redirecting
  • metadata is 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
<?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

bash
npm install stripe

Create session

javascript
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

javascript
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

  1. Stripe Dashboard → Developers → Webhooks → Add endpoint
  2. URL: https://yourdomain.com/webhooks/stripe (HTTPS required)
  3. 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

  1. Click Add endpoint
  2. On the endpoint's page, reveal "Signing secret" — starts with whsec_
  3. Save this to .env as STRIPE_WEBHOOK_SECRET

PHP webhook handler

php
<?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

javascript
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:

php
$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 checkout
  • customer.subscription.created — subscription is active
  • invoice.paid — a monthly / yearly charge succeeded
  • invoice.payment_failed — a renewal failed; typically notify the user + retry logic
  • customer.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:

php
$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 .env on production: replace sk_test_ / pk_test_ with sk_live_ / pk_live_
  • Create a new webhook endpoint in live mode (separate signing secret)
  • Update STRIPE_WEBHOOK_SECRET to 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

  1. Amount in major units instead of minor units. 500 for Rs 5, not Rs 500. Always multiply by 100.
  2. Missing `{CHECKOUT_SESSION_ID}` in success_url. Without it, you have no way to retrieve the session on return.
  3. Not verifying signature on the success URL. Trusting $_GET['session_id'] opens you to forgery. Always Session::retrieve().
  4. Express `express.json()` swallowing the webhook raw body. Signature verification fails forever. Use express.raw() on the webhook route only.
  5. Missing webhook idempotency. Stripe retries events. Same event arriving twice = order credited twice. Check event.id against a processed_events table.
  6. Not handling `invoice.payment_failed` for subscriptions. Users get cut off unfairly on transient card issues. Implement smart retry + grace periods.
  7. Testing with live keys accidentally. Check key prefixes before every deploy.
  8. 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).

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket