Fulfil orders with webhooks
Ship the goods, grant access or mark the order paid from your server, when Stripe tells it that the money arrived. This works for every Vue Stripe integration: Payment Element, Checkout (hosted, embedded or on a Checkout Session), bank debits and subscriptions.
Don't fulfil from the browser
The return page and the result of confirmPayment() only tell you what the customer's browser saw. A customer can close the tab before the redirect, open your success URL directly, or pay with a method that settles days later (SEPA, BECS, ACH). Use them to show a message, and fulfil from the webhook.
Before you start you need a server where you can add a route, and your secret key there. The examples use Node with Express and stripe-node, in JavaScript and TypeScript.
1. Add the webhook endpoint
Stripe signs every event. Verify the signature against the raw request body before you trust it, so mount the route with express.raw() and before any global express.json():
// server.js
import express from 'express'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET // whsec_...
const app = express()
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret)
} catch (err) {
return res.status(400).send(`Webhook signature check failed: ${err.message}`)
}
await handleEvent(event) // step 2
res.json({ received: true })
})
app.use(express.json()) // your other routes come after the webhook route// server.ts
import express from 'express'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET! // whsec_...
const app = express()
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'] ?? '',
endpointSecret
)
} catch (err) {
return res.status(400).send(`Webhook signature check failed: ${(err as Error).message}`)
}
await handleEvent(event) // step 2
res.json({ received: true })
})
app.use(express.json()) // your other routes come after the webhook routeIn TypeScript, process.env values and the signature header can be undefined, which new Stripe() and constructEvent() don't accept: hence the ! and the ?? ''. A request without a signature then fails the check with 400.
In other frameworks, pass the unparsed body to constructEvent(): await request.text() in a Next.js route handler, await readRawBody(event) in a Nuxt server route, await c.req.text() in a Hono route such as a Quasar SSR middleware, $request->getContent() in Laravel. Receive webhooks in the SSR guide has a typed Nuxt route, with the signing secret in runtimeConfig, and Receive webhooks in the Quasar guide has the Hono route for Quasar SSR.
Laravel
Laravel's CSRF check answers Stripe's POST with 419 before your code runs, so exempt the webhook route in bootstrap/app.php (or put it in routes/api.php). Receive webhooks in the Laravel guide has the route and a controller with \Stripe\Webhook::constructEvent().
2. Handle the events for your integration
Listen for the events that match how you take payments:
- Checkout Sessions (hosted, embedded, your own page, Checkout Form, pricing table): fulfil on
checkout.session.completedwhen the session'spayment_statusispaidorno_payment_required. Payment methods that settle later sendcheckout.session.async_payment_succeededorcheckout.session.async_payment_failedafterwards. - PaymentIntents (Payment Element, card, SEPA, BECS, ACH): fulfil on
payment_intent.succeeded. Bank debits sendpayment_intent.processingfirst (tell the customer the payment is pending) and settle days later: SEPA within a few business days, BECS in three to four, ACH within four. Failures sendpayment_intent.payment_failed. - Saved payment methods:
setup_intent.succeeded, orsetup_intent.setup_failed. - Subscriptions: grant access on
invoice.paid; handleinvoice.payment_failedandcustomer.subscription.deleted. - Identity:
identity.verification_session.verified, oridentity.verification_session.requires_input.
A Checkout Session pays with a PaymentIntent of its own, so one Checkout payment sends payment_intent.* events as well as checkout.session.* events; a subscription invoice does the same next to invoice.paid. Fulfil each payment from one object: Checkout Sessions from the checkout.session.* events, and PaymentIntents only when they carry the order ID that your server put on them, which those other PaymentIntents don't. Recording event.id can't catch the duplicate, because the two events have different IDs.
async function handleEvent(event) {
switch (event.type) {
// Checkout Sessions: the order ID is the session's client_reference_id
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
const session = event.data.object
if (session.client_reference_id && session.payment_status !== 'unpaid') {
await fulfilOrder(session.client_reference_id, session.id)
}
break
}
case 'checkout.session.async_payment_failed': {
const session = event.data.object
if (session.client_reference_id) await markOrderFailed(session.client_reference_id)
break
}
// PaymentIntents your server created: the order ID is in metadata. A Checkout
// Session's PaymentIntent has none; the session events above fulfil that payment.
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object
if (paymentIntent.metadata.orderId) {
await fulfilOrder(paymentIntent.metadata.orderId, paymentIntent.id)
}
break
}
case 'payment_intent.processing': {
const paymentIntent = event.data.object
if (paymentIntent.metadata.orderId) await markOrderPending(paymentIntent.metadata.orderId)
break
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object
if (paymentIntent.metadata.orderId) await markOrderFailed(paymentIntent.metadata.orderId)
break
}
}
}// server.ts: Stripe is imported in step 1
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
// Checkout Sessions: the order ID is the session's client_reference_id
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
const session = event.data.object
if (session.client_reference_id && session.payment_status !== 'unpaid') {
await fulfilOrder(session.client_reference_id, session.id)
}
break
}
case 'checkout.session.async_payment_failed': {
const session = event.data.object
if (session.client_reference_id) await markOrderFailed(session.client_reference_id)
break
}
// PaymentIntents your server created: the order ID is in metadata. A Checkout
// Session's PaymentIntent has none; the session events above fulfil that payment.
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object
if (paymentIntent.metadata.orderId) {
await fulfilOrder(paymentIntent.metadata.orderId, paymentIntent.id)
}
break
}
case 'payment_intent.processing': {
const paymentIntent = event.data.object
if (paymentIntent.metadata.orderId) await markOrderPending(paymentIntent.metadata.orderId)
break
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object
if (paymentIntent.metadata.orderId) await markOrderFailed(paymentIntent.metadata.orderId)
break
}
}
}fulfilOrder(), markOrderPending() and markOrderFailed() stand for your own code that updates the order. To find the order from the event, store its ID on the Stripe object when you create it:
// The PaymentIntent of your Payment Element page (Payment Element guide, step 1)
await stripe.paymentIntents.create({ /* amount, currency, ... */ metadata: { orderId: order.id } })
// A Checkout Session (Checkout guides, step 1)
await stripe.checkout.sessions.create({ /* line_items, mode, ... */ client_reference_id: order.id })Use client_reference_id rather than the session's metadata: the pricing table sets it from its client-reference-id attribute. Don't copy the order ID to the session's PaymentIntent (payment_intent_data.metadata): the payment_intent.* cases would then fulfil Checkout payments a second time.
3. Make fulfilment safe to repeat
Stripe retries an event until your endpoint answers with a 2xx status, and can deliver the same event more than once. So:
- Make
fulfilOrder()idempotent: check whether the order is already fulfilled before you act. Key the check on the order, not onevent.id: recording the event ID catches a redelivered event, but not a second event about the same payment. - Answer quickly. If fulfilment is slow (emails, shipping APIs), record the event, respond
200, and do the work in a background job. - Don't rely on the order in which events arrive; read the object's current
statuswhen it matters.
4. Test locally with the Stripe CLI
Forward test-mode events to your local server, naming the events from step 2:
stripe listen \
--events checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,payment_intent.succeeded,payment_intent.processing,payment_intent.payment_failed \
--forward-to localhost:3000/webhookThe Stripe CLI needs the event selection: version 1.53 refuses to start listen without --events, --all-snapshot or --all-thin, with "must specify events to forward". Use --all-snapshot instead of --events to forward every event while you explore.
Set STRIPE_WEBHOOK_SECRET to the whsec_... secret that stripe listen prints, then pay in your app with a test card and watch the events arrive. To send a single event without paying, trigger it:
stripe trigger checkout.session.completed
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failedFor bank debits, the delayed test accounts in Test your integration let you see processing followed by succeeded or payment_failed a few minutes later.
5. Register the endpoint for production
In the Stripe Dashboard, add a webhook endpoint (an event destination) with your public URL, for example https://example.com/webhook, and select the events from step 2. Each endpoint has its own signing secret: put the live one in STRIPE_WEBHOOK_SECRET on your production server.
6. Show the outcome on the return page
The webhook does the work; the page the customer returns to only reports it. For Checkout, put the session ID in the success URL and read the session on your server:
// success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}'
app.get('/api/checkout-status', async (req, res) => {
try {
const session = await stripe.checkout.sessions.retrieve(req.query.session_id)
res.json({ status: session.status, paymentStatus: session.payment_status })
} catch {
res.status(404).json({ error: 'No such Checkout Session' })
}
})// success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}'
app.get('/api/checkout-status', async (req, res) => {
try {
// req.query values can be arrays or objects; String() gives retrieve() a string
const session = await stripe.checkout.sessions.retrieve(String(req.query.session_id))
res.json({ status: session.status, paymentStatus: session.payment_status })
} catch {
res.status(404).json({ error: 'No such Checkout Session' })
}
})If your fulfilOrder() is idempotent (step 3), you can also call it here when payment_status is paid: the customer sees the result without waiting for the webhook, and the webhook still covers closed tabs and delayed payments. For PaymentIntents, read the status in the browser with retrievePaymentIntent(), as shown in Handle errors and declines.
Result
Orders are fulfilled once per payment, from a verified event, whether the customer's browser came back or not, and delayed bank payments are fulfilled when they settle.
