I have successfully implemented Stripe using their Embedded Components API. As I understand correctly, for maximum security products are set server-site in advance. This absolutely makes sense for a multi-page checkout process.
However, now I have a dashboard with user data already present, where the client can choose from a list of just 2 products and add them to his booking. The Stripe payment button is placed directly below the list. For everything else I used the template from Stripe's documentation.
checkout.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script src="https://js.stripe.com/clover/stripe.js"></script>
<script src="checkout.js" defer></script>
</head>
<body>
<!-- Display a payment form -->
<form id="payment-form">
<div class="product">
<label>Product 1 ($10)</label>
<input type="number" id="p1" value="0" min="0">
</div>
<div class="product">
<label>Product 2 ($20)</label>
<input type="number" id="p2" value="0" min="0">
</div>
<label>
Email
<input type="email" id="email"/>
</label>
<div id="email-errors"></div>
<h4>Payment</h4>
<div id="payment-element">
<!--Stripe.js injects the Payment Element-->
</div>
<button id="submit">
<span id="button-text">Pay now</span>
</button>
<div id="payment-message" class="hidden"></div>
</form>
</body>
</html>
checkout.js
const stripe = Stripe("pk_test_***");
let checkout;
let actions;
initialize();
const emailInput = document.getElementById("email");
const emailErrors = document.getElementById("email-errors");
const validateEmail = async (email) => {
const updateResult = await actions.updateEmail(email);
const isValid = updateResult.type !== "error";
return { isValid, message: !isValid ? updateResult.error.message : null };
};
document
.querySelector("#payment-form")
.addEventListener("submit", handleSubmit);
// Fetches a Checkout Session and captures the client secret
async function initialize() {
const promise = fetch('/create.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
})
.then((r) => r.json())
.then((r) => r.clientSecret);
const appearance = {
theme: 'stripe',
};
checkout = stripe.initCheckout({
clientSecret: promise,
elementsOptions: { appearance },
});
checkout.on('change', (session) => {
// Handle changes to the checkout session
});
const loadActionsResult = await checkout.loadActions();
if (loadActionsResult.type === 'success') {
actions = loadActionsResult.actions;
const session = loadActionsResult.actions.getSession();
document.querySelector("#button-text").textContent = `Pay ${
session.total.total.amount
} now`;
}
emailInput.addEventListener("input", () => {
// Clear any validation errors
emailErrors.textContent = "";
emailInput.classList.remove("error");
});
emailInput.addEventListener("blur", async () => {
const newEmail = emailInput.value;
if (!newEmail) {
return;
}
const { isValid, message } = await validateEmail(newEmail);
if (!isValid) {
emailInput.classList.add("error");
emailErrors.textContent = message;
}
});
const paymentElement = checkout.createPaymentElement();
paymentElement.mount("#payment-element");
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
const email = document.getElementById("email").value;
const { isValid, message } = await validateEmail(email);
if (!isValid) {
emailInput.classList.add("error");
emailErrors.textContent = message;
showMessage(message);
setLoading(false);
return;
}
const { error } = await actions.confirm();
showMessage(error.message);
setLoading(false);
}
// ------- UI helpers -------
function showMessage(messageText) {
const messageContainer = document.querySelector("#payment-message");
messageContainer.classList.remove("hidden");
messageContainer.textContent = messageText;
setTimeout(function () {
messageContainer.classList.add("hidden");
messageContainer.textContent = "";
}, 4000);
}
create.php
<?php
require_once '../vendor/autoload.php';
require_once '../secrets.php';
$stripe = new \Stripe\StripeClient([
"api_key" => $stripeSecretKey,
"stripe_version" => "2025-11-17.clover"
]);
header('Content-Type: application/json');
$checkout_session = $stripe->checkout->sessions->create([
'ui_mode' => 'custom',
'line_items' => [
[
'price_data' => [
'currency' => 'usd',
'unit_amount' => 1000,
'product_data' => [
'name' => 'Product 1',
'description' => 'Product 1 is what you need',
]
],
'adjustable_quantity' => [
'enabled' => true,
'minimum' => 0,
'maximum' => 10,
],
'quantity' => 1
],
[
'price_data' => [
'currency' => 'usd',
'unit_amount' => 2000,
'product_data' => [
'name' => 'Product 2',
'description' => 'You also need product 2',
]
],
'adjustable_quantity' => [
'enabled' => true,
'minimum' => 0,
'maximum' => 10,
],
'quantity' => 1
],
],
'mode' => 'payment',
'return_url' => $_SERVER['HTTP_REFERER'] . '/complete.html?session_id={CHECKOUT_SESSION_ID}',
]);
echo json_encode(array('clientSecret' => $checkout_session->client_secret));
?>
How can the product quantity of the Payment Intent be changed in this environment?
Thanks a lot in advance!