# After completion (/en/integrate/checkout-session/after-completion)

Send the customer back to your site, or let XPay show a hosted thank-you page. Plus how to handle failed payments and why the webhook is the source of truth.

`afterCompletion` decides what the customer sees the moment a payment succeeds. Two shapes: a **redirect** to a URL on your site, or a **hosted confirmation** page that XPay renders. Pick one per session; you can change it on `PATCH` while the session is still open.

A separate field, `cancelUrl`, decides where the customer goes if a payment **fails** on the hosted page (declined card, 3D Secure rejected, local-method timeout). It's optional and only valid for `uiMode: "hosted"`.

<Callout type="info">
  Whichever option you pick, &#x2A;*the redirect or confirmation page is not where you confirm the
  payment.** Your server must listen for the `checkout.session.completed` webhook to actually mark
  the order paid. Customers can close the tab, hit the URL by accident, or never reach the redirect
  at all.
</Callout>

## The two options [#the-two-options]

| `afterCompletion.type` | What the customer sees on success                                                     | When to pick it                                                                             | Available on                         |
| ---------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------ |
| `redirect`             | XPay sends them to your `redirect.url`. You render the thank-you page yourself.       | You have a return page that fits your brand and order context. Recommended for most sites.  | Every `uiMode`                       |
| `hosted_confirmation`  | XPay shows a built-in success page with an optional custom message and return button. | You don't have a return page (yet), or for one-off payments where building one is overkill. | `uiMode: "hosted"` and Payment Links |

`afterCompletion` itself is the only required field on `POST /checkout/sessions`. Sending neither field at all is a validation error.

<Callout type="warn">
  For `uiMode: "embedded"` (Drop-in) and `uiMode: "custom"` (Elements), `type` must be `"redirect"`.
  `hosted_confirmation` renders an XPay-hosted page, and those integrations run entirely on your own
  site. See [SDK integrations need a return URL](#sdk-integrations-need-a-return-url).
</Callout>

## Redirect to your URL [#redirect-to-your-url]

Set `type: "redirect"` and provide an HTTPS URL on a domain you control. XPay sends the customer to that URL right after the payment lands.

<Tabs items="[&#x22;cURL&#x22;, &#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="cURL">
    ```bash
    curl -X POST https://api.xpay.app/checkout/sessions \
      -H "Authorization: Bearer sk_test_..." \
      -H "Content-Type: application/json" \
      -d '{
        "afterCompletion": {
          "type": "redirect",
          "redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" }
        },
        "lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```typescript
    await fetch("https://api.xpay.app/checkout/sessions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        afterCompletion: {
          type: "redirect",
          redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
        },
        lineItems: [{ price: "price_test_xyz", quantity: 1 }],
      }),
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os, requests

    requests.post(
        "https://api.xpay.app/checkout/sessions",
        headers={
            "Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "afterCompletion": {
                "type": "redirect",
                "redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
            },
            "lineItems": [{"price": "price_test_xyz", "quantity": 1}],
        },
        timeout=10,
    )
    ```
  </Tab>
</Tabs>

A few rules:

* **`redirect.url` is required** when `type: "redirect"`. Sending `redirect` together with `type: "hosted_confirmation"` is rejected.
* **HTTPS, on a domain you control.** `localhost` is fine in test mode but rejected in live.
* **No query parameters added by XPay.** The customer lands on your URL with whatever you put in it. If you need to know which session this is, use the template token below.

### The `{CHECKOUT_SESSION_ID}` template [#the-checkout_session_id-template]

Drop the literal token `{CHECKOUT_SESSION_ID}` anywhere in `redirect.url` and XPay substitutes the session's ID before saving the URL. Useful when you don't want to thread the ID through your own state.

```jsonc
{
  "redirect": {
    "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}",
  },
}
```

For a session with `id: "cs_test_AbC123..."`, the customer is redirected to `https://yourshop.example/order/cs_test_AbC123...`. Read the `cs_*` from the path on your return page if you want to display the order details (call `GET /checkout/sessions/:id` to fetch the latest state).

## Show a hosted confirmation page [#show-a-hosted-confirmation-page]

Set `type: "hosted_confirmation"` if you don't have a return page. XPay shows the customer a thank-you page with your business name and a check mark, and that's the end of the flow.

Valid for `uiMode: "hosted"` and for Payment Links (which always run as hosted). Sending it with `uiMode: "embedded"` or `"custom"` returns a `400` on both create and update.

<Tabs items="[&#x22;cURL&#x22;, &#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="cURL">
    ```bash
    curl -X POST https://api.xpay.app/checkout/sessions \
      -H "Authorization: Bearer sk_test_..." \
      -H "Content-Type: application/json" \
      -d '{
        "afterCompletion": {
          "type": "hosted_confirmation",
          "hostedConfirmation": {
            "customMessage": "Thanks! Your order will ship within 2 business days.",
            "returnUrl": "https://yourshop.example"
          }
        },
        "lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```typescript
    await fetch("https://api.xpay.app/checkout/sessions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        afterCompletion: {
          type: "hosted_confirmation",
          hostedConfirmation: {
            customMessage: "Thanks! Your order will ship within 2 business days.",
            returnUrl: "https://yourshop.example",
          },
        },
        lineItems: [{ price: "price_test_xyz", quantity: 1 }],
      }),
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os, requests

    requests.post(
        "https://api.xpay.app/checkout/sessions",
        headers={
            "Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "afterCompletion": {
                "type": "hosted_confirmation",
                "hostedConfirmation": {
                    "customMessage": "Thanks! Your order will ship within 2 business days.",
                    "returnUrl": "https://yourshop.example",
                },
            },
            "lineItems": [{"price": "price_test_xyz", "quantity": 1}],
        },
        timeout=10,
    )
    ```
  </Tab>
</Tabs>

Both nested fields are optional.

| Field           | Default                                                                                                         | Notes                                                                                   |
| --------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `customMessage` | A statement-style line ending with your business name (the display name from your dashboard branding settings). | Up to 500 characters. Replaces the default text.                                        |
| `returnUrl`     | None. The page renders without a return button.                                                                 | When set, the page shows a "Return to *your business name*" button linking to this URL. |

`redirect` must not be provided when `type: "hosted_confirmation"`. Sending both is rejected.

## `cancelUrl`: where to send the customer on payment failure [#cancelurl-where-to-send-the-customer-on-payment-failure]

`cancelUrl` is the URL XPay redirects the customer to when a **payment attempt fails** on the hosted checkout page. It's optional, separate from `afterCompletion`, and only valid when `uiMode: "hosted"`.

```jsonc
{
  "afterCompletion": { "type": "redirect", "redirect": { "url": "..." } },
  "cancelUrl": "https://yourshop.example/checkout/declined",
  "lineItems": [ ... ]
}
```

What triggers a redirect to `cancelUrl`:

* A card was declined.
* 3D Secure was rejected, cancelled, or timed out.
* A local payment method (Fawry, Valu, etc.) timed out or was rejected by the processor.
* The processor returned an error.

What does **not** trigger a redirect to `cancelUrl`:

* The customer closing the tab. There's no client-side cancellation hook.
* The customer hitting the back button. The hosted page has no "Cancel" or "Back" button.
* The session expiring while open. That just shows a terminal "you're all done here" state on the hosted page.

If `cancelUrl` is omitted, the customer sees the failure inline on the hosted checkout page and can retry there. Setting it gives you a chance to log the attempt, surface a custom retry UX, or offer a fallback like a different payment method.

## SDK integrations need a return URL [#sdk-integrations-need-a-return-url]

For `uiMode: "embedded"` and `uiMode: "custom"`, `afterCompletion.type` must be `"redirect"`, which makes `redirect.url` mandatory in practice. Three separate rules stack up here, so to be precise about which is which:

| Rule                                               | Applies to                                       |
| -------------------------------------------------- | ------------------------------------------------ |
| `afterCompletion` is required                      | Every Checkout Session and every Payment Link    |
| `redirect.url` is required when `type: "redirect"` | Every `uiMode`                                   |
| `type` must be `"redirect"`                        | `uiMode: "embedded"` and `uiMode: "custom"` only |

Payment Links always run as `uiMode: "hosted"`, so the third rule never touches them. They can use `hosted_confirmation`.

## When verification takes over the page [#when-verification-takes-over-the-page]

This applies to `uiMode: "hosted"` and Drop-in. A bank verification never takes over an Elements page.

Most card payments verify with the bank without leaving the page. Occasionally the bank's page cannot be shown there at all.

XPay then hands the bank the whole tab instead of failing the payment. The customer verifies on the bank's own page and comes back to your `afterCompletion.redirect.url`.

Write your return page for it:

* **It is not a success page.** The customer returns whatever the outcome, including declines and abandoned verifications.
* **The payment is often not confirmed yet when they arrive.** Read the session and check `paymentStatus`. If it is not `paid`, tell the customer the payment is being confirmed. Do not tell them it failed, or they will pay a second time for a payment that is going through.
* **Nothing is appended to your URL.** Put `{CHECKOUT_SESSION_ID}` in it so the page knows which session to read.
* **`onComplete` does not fire.** For Drop-in, that callback belongs to a page that is gone.

Fulfillment is unchanged. `checkout.session.completed` is what marks the order paid, on this path exactly as on every other.

## What about Drop-in and Elements? [#what-about-drop-in-and-elements]

`afterCompletion` is the same field on every `uiMode`, but the runtime behavior shifts.

<Cards>
  <Card icon="<ExternalLink />" title="uiMode: hosted">
    XPay's hosted page navigates the browser to `redirect.url` (or shows the confirmation page).
    Your server runs nothing client-side. `cancelUrl` works here.
  </Card>

  <Card icon="<AppWindow />" title="uiMode: embedded (Drop-in)">
    The SDK opens the hosted checkout in an iframe. On success, the SDK fires your `onComplete`
    callback with the `redirectUrl` from `afterCompletion.redirect.url`. Your code decides whether to
    navigate, close the modal, or hand off to your own success view. XPay navigates the browser there
    itself only when a bank verification takes over the tab. `cancelUrl` and `hosted_confirmation`
    are both rejected at session create.
  </Card>

  <Card icon="<Component />" title="uiMode: custom (Elements)">
    Your code calls `confirmPayment()` and handles the success/failure result yourself. XPay renders
    nothing on your page except the payment element and the verification overlay. The only time the
    SDK navigates is `redirect: "always"`, which sends the customer to `redirect.url` after a
    successful payment. `cancelUrl` and `hosted_confirmation` are both rejected at session create.
  </Card>
</Cards>

The session payload that arrives in `checkout.session.completed` carries the same `afterCompletion` fields regardless of `uiMode`, so your server-side fulfillment code is identical across patterns.

## The webhook is the source of truth [#the-webhook-is-the-source-of-truth]

A redirect or hosted-confirmation render means the customer's browser saw the success state. It does not mean your server has the truth.

* **Customers close tabs.** The redirect never fires.
* **Networks drop.** The redirect arrives, but your return page can't reach your own backend.
* **People hit URLs by accident.** Anyone can craft a return URL with a stale `cs_*`.

Treat your return page (or hosted-confirmation page) as a UX courtesy, and your `checkout.session.completed` webhook handler as the single source of truth for fulfillment. The webhook payload carries the full Checkout Session, including the resolved `paymentIntent`, `customer`, and `lineItems`. See [Webhooks → Setting up an endpoint](/integrate/webhooks/setting-up-an-endpoint) for the handler recipe.

If you want to display the actual order on your return page, read it back with `GET /checkout/sessions/:id` from the path (assuming you used the `{CHECKOUT_SESSION_ID}` template).

## Where to next [#where-to-next]

<Cards>
  <Card icon="<SlidersHorizontal />" title="Advanced configuration" href="/integrate/checkout-session/advanced-configuration">
    Branding, locale, payment methods, fees, and metadata.
  </Card>

  <Card icon="<Webhook />" title="Set up a webhook endpoint" href="/integrate/webhooks/setting-up-an-endpoint">
    Receive `checkout.session.completed` and verify the signature.
  </Card>

  <Card icon="<BadgeCheck />" title="Confirm with a webhook" href="/integrate/integration-patterns/hosted-checkout#confirm-with-a-webhook">
    The full handler walkthrough on the Hosted Checkout pattern page.
  </Card>
</Cards>