Stripe in a Node.js and Angular App: Checkout vs Payment Element, Webhooks and Idempotency
Which Stripe integration to pick for an Express and Angular store, how to create a Checkout Session safely, verify webhook signatures with the raw body, make fulfilment idempotent, use idempotency keys, model order state in MongoDB and avoid double shipments.

For most Angular and Node.js stores, use Stripe Checkout with the Checkout Sessions API, and mark an order as paid only from a signed webhook, never from the page the customer lands on. Stripe's documentation calls Checkout the best choice for most integrations, and recommends the Payment Element together with the Checkout Sessions API when you want the payment form inside your own page. The server creates the session with prices it looks up itself, Stripe collects the payment, and a checkout.session.completed webhook, verified against the raw request body, moves the order from pending to paid exactly once.
The rest of this post explains how to choose, then walks through the server code for creating a session, receiving webhooks and making fulfilment safe to repeat, with idempotency keys for your own API calls. It is the shape I use for the e-commerce work described on my Stripe e-commerce development page, and every Stripe behaviour below comes from docs.stripe.com as checked on 5 November 2026.
Checkout or Payment Element?
Stripe describes Checkout as a low-code integration that creates a customisable payment form, supports more than 100 payment methods and covers one-time payments and subscriptions. You can redirect to a Stripe-hosted page, embed Checkout in your site, or build a custom page with Stripe Elements. The Checkout Session has a ui_mode that defaults to hosted_page, with embedded_page, form and elements as the other values.
| Option | Where the form lives | Good fit | Trade-off |
|---|---|---|---|
| Checkout, hosted page | On Stripe's domain | Fastest route, least code, tax, discounts, shipping and wallets built in | Customer leaves your site; branding limited to Stripe's options |
| Checkout, embedded page | Inside your page | Same features, stays on your domain | Uses return_url, not success_url; slightly more front-end work |
| Payment Element with the Checkout Sessions API | Your page, your layout | Custom checkout design with Stripe handling tax, discounts, shipping and subscriptions | More Angular code and a different confirm flow |
| Payment Element with PaymentIntents | Your page | You must own the whole checkout, including tax, discounts and currency conversion | Stripe says it requires significantly more code |
My rule of thumb: start with hosted Checkout, move to embedded or the Payment Element when design requires it, and use PaymentIntents only when you have a real reason to calculate everything yourself. Stripe's Payment Element page says it recommends the Checkout Sessions API over Payment Intents for most integrations.
In Angular you do not need a wrapper library for any of these. For hosted Checkout the browser only receives a URL and redirects. For the embedded and Element options, install @stripe/stripe-js and call loadStripe(). The package README is explicit that you must load Stripe.js from https://js.stripe.com, and cannot bundle it or host it yourself.
Step 1: create the Checkout Session on the server
Stripe's quickstart is clear that sensitive product information such as price and availability must stay on your server to prevent customer manipulation. The Angular app sends item IDs and quantities, and the API prices them from its own database. Pass a client_reference_id (up to 200 characters) and metadata so a webhook can find your order. A session expires after 24 hours by default, and expires_at can be anywhere from 30 minutes to 24 hours after creation.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post('/api/checkout', requireUser, async (req, res) => {
const order = await Order.createPending(req.user.id, req.body.items); // prices come from YOUR database
const session = await stripe.checkout.sessions.create(
{
mode: 'payment',
line_items: order.items.map((item) => ({ price: item.stripePriceId, quantity: item.quantity })),
client_reference_id: String(order.id),
metadata: { orderId: String(order.id) },
success_url: 'https://example.com/orders/thanks?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cart',
},
{ idempotencyKey: 'checkout-' + order.id },
);
await order.attachSession(session.id);
res.status(201).json({ url: session.url });
});
On the Angular side the component posts the cart and follows the URL:
checkout(items: CartItem[]) {
this.http.post<{ url: string }>('/api/checkout', { items }).subscribe(({ url }) => {
window.location.assign(url);
});
}
The {CHECKOUT_SESSION_ID} placeholder in success_url is replaced by Stripe with the real session ID. Validate the request body with a strict schema, as in Building a Type-Safe Express 5 API with Zod, and make sure a customer cannot change another customer's order.
Step 2: idempotency keys for your own API calls
Stripe's API supports idempotency so you can retry a request after a network error without doing the work twice. You send a unique key, and for API v1 Stripe saves the status code and body of the first request made with that key and returns the same response to retries, including 500 errors. Details that matter in practice:
- Keys can be up to 255 characters, and Stripe suggests V4 UUIDs or another random string with enough entropy. Do not use email addresses or personal data.
- All
POSTrequests accept a key. Do not send keys onGETorDELETE, where they have no effect. - Keys can be removed once they are at least 24 hours old, and a reused key after that creates a new request.
- Stripe compares the incoming parameters with the original request and returns an error if they differ.
That last rule shapes how you derive a key. In the example above the key is built from the order ID, so a retry of the same order returns the same session. If the customer changes the cart, create a new order, which gets a new key, instead of reusing the old one with different line items. The Node library also retries some failed requests automatically since version 13, and its README says idempotency keys are added where appropriate. Pass your own key anywhere a duplicate would cost money, such as creating a refund.
Step 3: receive webhooks and verify the signature
Stripe's fulfilment guide says you cannot rely on the landing page to trigger fulfilment, because a customer can pay and then lose their connection before the page loads. Webhooks are required, and mandatory for payment methods with delayed success notification. The endpoint must be a publicly accessible HTTPS URL in live mode (TLS 1.2 or 1.3). Do three things in the handler:
- Verify the signature with the
Stripe-Signatureheader and the endpoint'swhsec_secret. Stripe warns that an unverified endpoint lets an attacker send fake events to trigger fulfilment. - Use the raw body. Stripe requires the exact bytes it sent, and any manipulation makes verification fail. In Express that means
express.raw()on this route, registered beforeexpress.json(). - Return a 2xx quickly, before complex work that could time out.
The official libraries apply a default tolerance of 5 minutes between the signature timestamp and the current time, as replay protection. Do not set it to 0, which disables the check. Keep your server clock synchronised with NTP.
// Register this route BEFORE app.use(express.json())
app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer, exactly as Stripe sent it
req.get('stripe-signature') ?? '',
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send('Invalid signature');
}
if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
await fulfillCheckout(event.data.object.id); // throws on failure, so Stripe retries
}
res.sendStatus(200);
});
For local development, the Stripe CLI forwards events to your machine and prints the signing secret to use: stripe listen --forward-to localhost:4242/webhook. Use the secret the CLI prints locally and the secret of the registered endpoint in production, because each endpoint has its own.
Step 4: make fulfilment safe to run twice
Stripe's guide says your fulfilment function might be called multiple times, possibly at the same time, for the same Checkout Session, because it runs from the webhook and from your landing page. It must handle repeated calls, retrieve the session with line_items expanded, check payment_status, fulfil, and record that it did. Delayed payment methods, such as bank debits, complete the session before the money arrives, and send checkout.session.async_payment_succeeded later, which is why the handler above listens to both events. The guide checks that the status is not unpaid.
In MongoDB, the cleanest way to be idempotent is an atomic state change: only one caller can move an order from pending to paid.
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ['line_items'] });
if (session.payment_status === 'unpaid') return; // a delayed payment is still pending
const order = await Order.findOneAndUpdate(
{ stripeSessionId: session.id, status: 'pending' },
{ status: 'paid', paidAt: new Date(), amountPaid: session.amount_total },
); // returns the order as it was; null if someone already moved it
if (!order) return; // already fulfilled, or not our session
// Compare session.amount_total and currency with the order here before you ship anything.
await reserveStockAndSendReceipt(order);
}
Two follow-ups complete the design. First, the duplicate-event advice in Stripe's docs: you can log the event IDs you have processed, and in some cases two separate Event objects are sent for the same thing, so use the ID of data.object with the event type to spot those. The atomic update above covers this without a separate table. Second, side effects after the state change can still fail, for example an email provider outage. Record them as separate steps or queue them, and retry them, so a failure does not leave a paid order that nobody ships. Stripe also recommends processing events through an asynchronous queue, because a spike, such as a monthly subscription renewal, can overwhelm a synchronous endpoint.
Order states that hold up
| State | Set when | Set by |
|---|---|---|
pending | Order created, session attached | Your API, step 1 |
paid | Session complete and not unpaid | Webhook (and landing page) via the atomic update |
fulfilled | Stock reserved, receipt sent, shipping started | Your fulfilment job |
expired | checkout.session.expired received | Webhook; release stock held for the cart |
payment_failed | checkout.session.async_payment_failed received | Webhook; email the customer |
Put a unique index on stripeSessionId, and never move an order backwards because of an event that arrived late. Stripe does not guarantee that events arrive in the order they were generated, so do not depend on it, and do not use the created timestamp to order them, because distinct events can share one.
The landing page
Stripe recommends also triggering fulfilment from your landing page, so a customer who is present sees the result straight away. The success page extracts session_id from the URL, calls the same fulfilment function and renders the outcome. When a webhook endpoint listens for checkout.session.completed and you set a success_url, Checkout waits up to 10 seconds for your server to answer the webhook before it redirects the customer, so keep that handler fast, and do not rely on this for correctness. With embedded Checkout, return_url plays the same role.
Common mistakes
- Trusting the redirect. Landing on the success URL proves nothing. Check the session on the server.
- Parsing the body before verifying. A global
express.json()in front of the webhook route breaks signature checks. - Prices sent by the browser. Always price from your database or from Stripe Price IDs.
- No dedupe. Webhook endpoints can receive the same event more than once, and Stripe retries failed deliveries for up to three days in live mode, with exponential back-off. In a sandbox it retries three times over a few hours.
- Returning 200 before saving anything. If you acknowledge an event and then fail, Stripe will not resend it. Return a non-2xx status when the work failed, and use the Dashboard or the CLI to resend events later (up to 15 days in the Dashboard, 30 with the CLI).
- Redirects on the webhook URL. Stripe counts a 3xx response as a failure.
- Listening to every event. Subscribe only to the types you use.
- Leaking secrets. Keep the secret key and the
whsec_secret out of the Angular bundle and the repository. Stripe lets you roll an endpoint secret and keep the old one valid for up to 24 hours while you deploy the new one.
Test before you ship
Stripe's quickstart lists test cards: 4242 4242 4242 4242 succeeds, 4000 0025 0000 3155 requires 3D Secure authentication and 4000 0000 0000 9995 is declined. Run the success path, the 3D Secure path and a declined payment, then use stripe trigger and the Dashboard's resend button to confirm that a repeated event leaves the order unchanged. Keep the Node runtime supported as well: the stripe-node README says it supports all LTS versions of Node.js 20 and later, so see which Node.js version to run in production.
Short FAQ
Do I need webhooks if I use hosted Checkout?
Yes. Stripe's guide says webhooks are required for reliable fulfilment, and mandatory for subscriptions and delayed payment methods.
Snapshot or thin events?
Stripe's webhook docs now describe thin events as the recommended payload for new integrations in general. The Checkout fulfilment guide, and the code in this post, use the classic snapshot event checkout.session.completed with constructEvent(). Follow the current fulfilment guide for the event you handle.
Can the same endpoint handle test and live mode?
It can, but Stripe generates a different signing secret for each mode, and for each endpoint. I register separate endpoints and keep the secrets in separate environment variables.
Sources
All pages were checked on 5 November 2026.
- Stripe: How Checkout works
- Stripe: Payment Element (Checkout Sessions API versus Payment Intents)
- Stripe API: Create a Checkout Session
- Stripe: Checkout quickstart (server-side prices, test cards)
- Stripe: Fulfill orders
- Stripe: Receive Stripe events in your webhook endpoint
- Stripe API: Idempotent requests
- stripe-node and stripe-js READMEs
- Express 5: express.raw()




