docs
Integrate

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

EventpaymentStatusWhat you do
checkout.session.completedunpaidRecord the order as awaiting payment. Do not fulfil.
checkout.session.async_payment_succeededpaidFulfil the order.
checkout.session.async_payment_failedunpaidClose 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.

Where to next

On this page