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.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.
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.
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.urlis required whentype: "redirect". Sendingredirecttogether withtype: "hosted_confirmation"is rejected.- HTTPS, on a domain you control.
localhostis 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.
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.
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.
| 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 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.
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
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 notpaid, 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. onCompletedoes 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?
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 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.
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.
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
Customer lifecycle
Decide who the customer is, what the form collects, and how XPay's customer record evolves across one-time payers and returning customers.
Advanced configuration
Submit button text, locale, branding, payment-method restrictions, promotion codes, fees, expiration, and metadata. Everything else you can set on a Checkout Session.