Asynchronous payments
Some payment methods are paid after checkout. The session completes first and the money arrives later, so fulfil on paymentStatus, not on completion.
Fawry is available on every account and on every integration, including Hosted Checkout and Payment Links, so every webhook handler must handle this. With Fawry, the customer gets a reference number at checkout and pays it later at an outlet or in the Fawry app. The checkout is over before the money arrives.
One rule covers it: status says whether the customer is done with checkout, paymentStatus says whether the money has arrived. Fulfil on paymentStatus, never on status alone.
checkout.session.completed arrives with paymentStatus: "unpaid" for a Fawry payment. A handler
that fulfils on the event without reading paymentStatus ships orders that were never paid.
The three events
| Event | paymentStatus | What you do |
|---|---|---|
checkout.session.completed | unpaid | Record the order as awaiting payment. Do not fulfil. |
checkout.session.async_payment_succeeded | paid | Fulfil the order. |
checkout.session.async_payment_failed | unpaid | Close the order. The customer needs a new session. |
Cards and ValU fire checkout.session.completed with paymentStatus: "paid" and nothing else.
data.object on all three is the full Checkout SessionAPI, so one handler serves every event. The reference the customer pays is at paymentIntent.nextAction.displayVoucherDetails (reference, expiresAt, instructions).
One fulfilment function
Guard fulfilment on paymentStatus and call it from both success events. Make it safe to run twice for the same session: deliveries are retried.
async function fulfillCheckout(session: CheckoutSession) {
if (session.paymentStatus !== "paid") return;
if (await orders.isFulfilled(session.id)) return;
await orders.fulfill(session.id, session.lineItems);
}
app.post("/webhooks/xpay", async (req, res) => {
const event = verifyAndParse(req);
const session = event.data.object;
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await fulfillCheckout(session);
break;
case "checkout.session.async_payment_failed":
await orders.markUnpaid(session.id);
break;
}
res.sendStatus(200);
});What the customer sees
On Hosted Checkout the customer sees the reference, presses Done, and lands on the confirmation page in its awaiting-payment state or on your afterCompletion.redirect.url. Reopening the session shows the reference until it is paid.
With Drop-in and Elements the reference opens in the same overlay as a 3D Secure challenge. On Done, confirm() resolves with { type: "success", session } where session.status.paymentStatus is "unpaid", and Drop-in's onComplete fires with paymentStatus: "unpaid". Show an awaiting-payment page, not a thank-you page.
A completed session cannot be paid another way. A customer who changes their mind starts a new session.