docs
IntegrateCheckout 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".

Whichever option you pick, 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.

The two options

afterCompletion.typeWhat the customer sees on successWhen to pick it
redirectXPay 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.
hosted_confirmationXPay 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.

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

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.

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 } ]
  }'
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 }],
  }),
});
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,
)

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

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.

{
  "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

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.

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 } ]
  }'
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 }],
  }),
});
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,
)

Both nested fields are optional.

FieldDefaultNotes
customMessageA statement-style line ending with your business name (the display name from your dashboard branding settings).Up to 500 characters. Replaces the default text.
returnUrlNone. 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 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".

{
  "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.

What about Drop-in and Elements?

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

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.

uiMode: embedded (Drop-in)

The SDK opens the hosted checkout in an iframe. On success, the SDK fires your onComplete callback with the redirectUrl extracted from afterCompletion.redirect.url. Your code decides whether to navigate, close the modal, or hand off to your own success view. cancelUrl is rejected at session create.

uiMode: custom (Elements)

Your code calls confirmPayment() and handles the success/failure result yourself. afterCompletion still travels with the session (and shows up on the webhook payload), but XPay doesn't navigate or render anything. cancelUrl is rejected at session create.

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

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 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

On this page