Stripe Checkout v Node.js: platba kartou, webhook a testování
Stripe Checkout krok za krokem: vytvoření Checkout Session, přesměrování, ověření webhooku, idempotentní vyřízení objednávky a testování.
Stripe Checkout je hostovaná platební stránka Stripe. Nemusíte řešit formulář na kartu, 3D Secure ani PCI DSS – zákazníka přesměrujete na Stripe a ten vám po zaplacení dá vědět. Ukážeme kompletní integraci v Node.js s Expressem, včetně části, kterou mnoho tutoriálů vynechá: spolehlivého potvrzení platby webhookem.
Jak tok vypadá
- Zákazník klikne na „Zaplatit“.
- Váš server vytvoří Checkout Session a vrátí její URL.
- Prohlížeč přesměruje zákazníka na Stripe, ten zaplatí.
- Stripe přesměruje zpět na vaši
success_url. - Stripe pošle webhook
checkout.session.completed– teprve ten je spolehlivý důkaz platby.
Krok 4 nesmí nic aktivovat. Zákazník může zavřít prohlížeč před přesměrováním, nebo naopak URL ručně otevřít bez placení.
Příprava
- Účet na Stripe a testovací klíče z Dashboardu (začínají
sk_test_). - Node.js 18+ a balíček
stripe:
npm install stripe express
export STRIPE_SECRET_KEY="sk_test_..."
export STRIPE_WEBHOOK_SECRET="whsec_..." # získáte níže
Tajný klíč nikdy nedávejte do frontendu ani do repozitáře.
Vytvoření Checkout Session
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post("/checkout", express.json(), async (req, res) => {
// Cenu a položky vždy určuje server, nikdy nevěřte ceně z prohlížeče.
const order = await db.orders.createPending({ userId: req.user.id, productId: "ebook-sql" });
const session = await stripe.checkout.sessions.create(
{
mode: "payment",
line_items: [
{
price_data: {
currency: "czk",
product_data: { name: "E-book: SQL v praxi" },
unit_amount: 29900, // v haléřích = 299,00 Kč
},
quantity: 1,
},
],
customer_email: req.user.email,
client_reference_id: String(order.id),
metadata: { orderId: String(order.id) },
success_url: "https://example.cz/dekujeme?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://example.cz/kosik",
},
{ idempotencyKey: `checkout-order-${order.id}` }
);
await db.orders.setStripeSession(order.id, session.id);
res.json({ url: session.url });
});
Důležité detaily:
unit_amountje v nejmenší jednotce měny – u CZK v haléřích.{CHECKOUT_SESSION_ID}doplní Stripe sám, nepřepisujte ho.metadataaclient_reference_idpropojí platbu s vaší objednávkou.- Pro opakovaně prodávané produkty je čistší vytvořit v Dashboardu Price a předat
price: "price_..."místoprice_data. - Pro předplatné použijte
mode: "subscription"s opakující se cenou.
Na frontendu stačí:
const res = await fetch("/checkout", { method: "POST" });
const { url } = await res.json();
window.location.href = url;
Webhook
Stripe podepisuje webhooky. Ověření potřebuje raw tělo požadavku, proto route registrujte s express.raw() a mimo globální express.json():
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Neplatný podpis: ${err.message}`);
}
// Deduplikace – Stripe může stejnou událost poslat víckrát.
const isNew = await db.webhookEvents.insertIfNew(event.id, event.type);
if (!isNew) return res.sendStatus(200);
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
const session = event.data.object;
if (session.payment_status === "paid") {
await fulfillOrder(session.metadata.orderId, session.id);
}
break;
}
case "checkout.session.async_payment_failed":
await db.orders.markFailed(event.data.object.metadata.orderId);
break;
}
res.sendStatus(200);
}
);
constructEvent ověří podpis i časové razítko (výchozí tolerance 5 minut). payment_status kontrolujeme proto, že u odložených platebních metod přijde checkout.session.completed dřív, než jsou peníze potvrzené – zaplacení pak oznámí až async_payment_succeeded.
Idempotentní vyřízení
async function fulfillOrder(orderId, sessionId) {
// Atomický přechod stavu: vyřídí se jen jednou, i při souběžných webhoocích.
const updated = await db.query(
"UPDATE orders SET status = 'paid', paid_at = now() WHERE id = $1 AND status = 'pending' RETURNING id",
[orderId]
);
if (updated.rowCount === 0) return;
await emailQueue.add("send-ebook", { orderId });
}
Webhook má odpovědět rychle. Odeslání e-mailu a další práci dejte do fronty.
Lokální testování se Stripe CLI
stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe
Příkaz vypíše webhook secret whsec_... pro lokální vývoj – nastavte ho do STRIPE_WEBHOOK_SECRET. V dalším terminálu můžete události vyvolat:
stripe trigger checkout.session.completed
Nebo projděte celý tok v prohlížeči s testovacími kartami:
| Číslo karty | Výsledek |
|---|---|
4242 4242 4242 4242 |
úspěšná platba |
4000 0025 0000 3155 |
vyžaduje 3D Secure ověření |
4000 0000 0000 9995 |
zamítnutí (nedostatek prostředků) |
Platnost libovolné budoucí datum, CVC libovolné tři číslice.
Stránka „Děkujeme“
Na success_url můžete session načíst a zobrazit stav, ale nic neaktivujte:
const session = await stripe.checkout.sessions.retrieve(req.query.session_id);
Pokud webhook ještě nedorazil, zobrazte „Platba se zpracovává“ a stav kontrolujte z vlastní databáze.
Přechod do produkce
- Vyměňte testovací klíče za živé (
sk_live_). - V Dashboardu v sekci Webhooks přidejte produkční endpoint a vyberte jen potřebné události. Každý endpoint má vlastní
whsec_secret. - Zkontrolujte, že endpoint běží na HTTPS a nestojí za přihlášením nebo CSRF ochranou.
- Sledujte v Dashboardu neúspěšná doručení webhooků – Stripe je opakuje, ale jen omezenou dobu.
Obecné zásady pro příjem webhooků od jakékoli služby shrnuje článek webhooky bezpečně.