Skip to main content
Being Idea Innovations
Stripe Payment Integration in PHP: Payment Intents, SCA and Webhooks
Back to Blog

Stripe Payment Integration in PHP: Payment Intents, SCA and Webhooks

19 May 20185 min readPHP
Share:

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.

Why the old approach is gone

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.js v2 is retired. The current version is v3, and Stripe.card.createToken no longer exists.
  • It destroys your PCI scope. Because the card number passes through your own DOM, you fall under SAQ D — roughly 300 questions and, above certain volumes, an external audit. Stripe Elements and Checkout keep the card inside a Stripe-hosted iframe, so the data never touches your page and you qualify for SAQ A, which is about 20 questions. This distinction is worth real money and effort.
  • It predates Strong Customer Authentication. Since 2021, SCA under PSD2 requires two-factor authentication on most European card payments. The token-then-charge flow has no way to present a 3D Secure challenge, so those payments simply decline.

Payment Intents exist specifically to handle that authentication step. That is why the API changed.

Setup

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.

Option 1: Checkout Sessions (recommended)

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.

Option 2: Payment Intents with Elements

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.

Confirm with webhooks, not the redirect

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);
}

Testing

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:

NumberBehaviour
4242 4242 4242 4242Succeeds
4000 0025 0000 3155Requires 3D Secure authentication
4000 0000 0000 9995Declined — insufficient funds
4000 0000 0000 0002Declined — 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.

Checklist

  • Use Checkout or Elements — never collect card fields yourself.
  • Stripe.js v3 only; v2 is retired.
  • Amounts in the smallest currency unit, as integers.
  • Read prices from your database, never from the request.
  • Secret key server-side; roll it immediately if exposed.
  • Verify webhook signatures with the raw body.
  • Deduplicate by event ID — Stripe retries.
  • Fulfil on the webhook, not the redirect.
  • Send an idempotency key on create calls.
  • Test the 3D Secure path.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers