Overview
The Checkout Session is the central object every integration creates. The same shape comes back from POST, GET, the SDK client, and every checkout.session webhook.
A Checkout Session represents one customer's attempt to pay you. Your server creates it, XPay returns the session object, and from there every integration pattern is just a different way to render the same object: a hosted page, a payment link, an SDK drop-in, or fully custom Elements.
The session object you get back from POST /checkout/sessions, the one you GET later, and the data.object carried on every checkout.sessionAPI webhook are all the same shape. There is one DTO and one mapper behind all three. If you build against the create response, your webhook handler reads the same fields by the same names.
For how Checkout Session relates to Payment Intent, Charge, Refund, Customer, and Balance Transaction, see Object model.
How it fits
Pick the integration pattern that matches how much UI control you want. Each pattern guide spells out the build, the test flow, the webhook handler, and the production checklist for that surface. They all create a Checkout Session the same way.
Payment Links
No-code. Share a https://checkout.xpay.app/p/plink_... URL. A session is created on the customer's first interaction.
Hosted Checkout
Server-only. Create a session, redirect the customer to session.url, confirm with a webhook.
Drop-in
Server plus a few lines of frontend. Open the checkout in a modal or inline iframe on your domain.
Elements
Server plus custom UI. Build the form yourself with <PaymentElement /> and confirmPayment().
Lifecycle
A session moves through two short state machines: status for the session itself and paymentStatus for the money.
status
| State | When it's set | What you do |
|---|---|---|
open | Set at creation. The session accepts mutations and is renderable in the checkout UI. | Wait for the customer to pay, or for the session to expire. |
complete | Set when payment succeeds. The session is locked: no more mutations, no more payment attempts. | Fulfill the order against checkout.session.completed. |
expired | Set when expiresAt passes, or when you call POST /checkout/sessions/:id/expire. The session can't be reused. | Create a new session if the customer wants to try again. |
paymentStatus
| State | When it's set |
|---|---|
unpaid | Default. No successful charge has occurred. |
paid | A charge has succeeded for the session's full amount. |
no_payment_required | The session was created in setup mode (collecting payment details only). |
The paymentIntent field on the response is null until the customer submits the form for the first time. After that, it carries the full Payment IntentAPI shape, identical to what GET /payment-intents/:id returns. ChargesAPI and RefundsAPI are nested inside the Payment Intent.
Creating a session
The only required field on POST /checkout/sessions is afterCompletion. Everything else has a default. In practice you'll also send lineItems so the customer sees what's being charged.
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": [
{
"priceData": {
"currency": "EGP",
"unitAmount": 149900,
"productData": { "name": "Test product" }
},
"quantity": 1
}
]
}'const res = 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: [
{
priceData: {
currency: "EGP",
unitAmount: 149900, // 1,499.00 EGP, in minor units
productData: { name: "Test product" },
},
quantity: 1,
},
],
}),
});
const session = await res.json();import os, requests
res = 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": [
{
"priceData": {
"currency": "EGP",
"unitAmount": 149900,
"productData": {"name": "Test product"},
},
"quantity": 1,
},
],
},
timeout=10,
)
session = res.json()The response carries the full session, including the two credentials your integration will use next:
{
"id": "cs_test_AbC123...",
"object": "checkout.session",
"status": "open",
"paymentStatus": "unpaid",
"url": "https://checkout.xpay.app/c/cs_test_AbC123...",
"clientSecret": "cs_test_AbC123..._secret_xyz",
"amountTotal": 149900,
"currency": "EGP",
"afterCompletion": { "type": "redirect", "redirect": { "url": "..." } },
"paymentIntent": null,
"livemode": false
}Use url to redirect the customer (Hosted Checkout, Payment Links). Use clientSecret to mount the SDK (Drop-in, Elements). The literal {CHECKOUT_SESSION_ID} token in redirect.url is replaced server-side with the session's id before the URL is stored, so your return page can read it off the path without you threading it through query parameters.
The full set of fields you can configure is grouped by purpose. Each group has its own page with the field-by-field detail.
Line items & pricing
Existing prices vs inline priceData, quantities, adjustable quantities, custom-amount lines, currency.
Customer lifecycle
customerId vs customerDetails, customerCreation, customerUpdate, name, phone, address, shipping, custom fields.
After completion
redirect vs hosted_confirmation, cancelUrl, return-button URL, custom message.
Advanced configuration
Branding, locale, payment-method types, fees and VAT pass-through, expiration, metadata.
Modes
Two enums on the session decide what kind of payment it represents and how the customer interacts with it. Both are set at creation and immutable for the rest of the session's life.
mode
mode is the kind of money movement. It defaults to payment.
| Value | Meaning |
|---|---|
payment | One-time charge. The default for nearly every integration. |
subscription | Recurring billing. Not yet supported on the checkout API. |
setup | Collect a payment method without charging it. paymentStatus resolves to no_payment_required. |
uiMode
uiMode is how the customer reaches the form. It defaults to hosted. The choice is tied to the integration pattern you picked above; the session's behavior, the SDK call you make, and the fields you can set all change with it.
| Value | Where the form runs | Use it with |
|---|---|---|
hosted | XPay's hosted page at https://checkout.xpay.app/c/cs_test_.... Server returns a url. | Hosted Checkout, Payment Links. |
embedded | An iframe on your site, opened by the SDK as a modal or inline. | Drop-in. |
custom | Your own form, using the Elements SDK and confirmPayment(). | Elements. |
A few rules fall out of this:
cancelUrlis only valid whenuiMode: "hosted". It's the redirect XPay sends the customer to if a payment attempt fails on the hosted page (declined, 3DS rejected, local-method timeout). It is not a customer-cancel button: the hosted page does not have one.uiMode: "custom"means your code owns customer collection. The session rejectsnameCollection,phoneNumberCollection,billingAddressCollection,shippingAddressCollection, andsubmitTypein this mode.- For
embeddedandcustom, you authenticate the SDK with the session'sclientSecret. The publishable key alone is not enough; the secret scopes the SDK to one specific session.
Mutually exclusive fields
A handful of pairs on the create body are exclusive. The API rejects requests that send both, and the same rules apply on PATCH.
| Either | Or | Why |
|---|---|---|
customerId | customerDetails | An existing CustomerAPI's record is the source of prefill, or you provide raw details. Not both. |
customerId | customerCreation: "always" | "Always create a new customer" contradicts "use this existing customer." |
paymentMethodTypes | paymentMethodConfigurationId | Pick a literal list of methods, or reference a saved configuration. |
lineItem.price | lineItem.priceData | Each line item references an existing PriceAPI by id, or it inlines product and price data. |
A CUSTOM-type line | allowPromotionCodes, discounts | Custom-amount lines (customer enters the amount) are not compatible with discounts. |
customerUpdate (which controls whether checkout writes collected data back onto the customer) is only meaningful when customerId is provided. Sending it without customerId is a validation error.
What's mutable
PATCH /checkout/sessions/:id updates an open session. It accepts every field on the create body except the ones below, which are locked at creation:
modeuiModesubmitTypecurrencyexpiresAfterMinutes
lineItems on PATCH is a full replacement: the new array overwrites the old one. To change a single item's quantity, send the entire desired array. Sessions in complete or expired status are read-only and reject PATCH.
Expiration
Every session has an expiresAt. Default is 24 hours after creation; minimum is 30 minutes. Set expiresAfterMinutes on the create body to change it.
Two things mark a session as expired:
expiresAtpasses. The session is no longer usable for payment; the hosted page renders a terminal "you're all done here" state.- You explicitly call
POST /checkout/sessions/:id/expire. Useful when you want to stop accepting payment on a session you no longer intend to honor, or to free up redemption slots on a one-shot promotion code.
Both paths emit a checkout.session.expired webhook.
Webhooks
Two events are emitted for the session's own lifecycle. The data.object on each one is a full Checkout SessionAPI, identical in shape to what GET /checkout/sessions/:id returns.
| Event | When |
|---|---|
checkout.session.completed | Payment succeeded. status is complete, paymentStatus is paid (or no_payment_required for setup mode). |
checkout.session.expired | The session expired by time or by an explicit /expire call. |
A successful payment on Hosted Checkout emits four events. They are dispatched in this sequence:
payment_intent.createdcharge.succeededpayment_intent.succeededcheckout.session.completed
Webhook delivery is asynchronous and runs with retries, so do not rely on receipt order in your handler. Key fulfillment off checkout.session.completed and treat the earlier events as supplementary context. The earlier ones are useful when you need to react to the underlying Payment IntentAPI or ChargeAPI directly. See the Event reference for the full list.
Where to next
Object model
How Checkout Session relates to Payment Intent, Charge, Customer, and Refund. The IDs to keep on your order record.
Line items & pricing
Build the cart: existing prices, inline priceData, quantities, custom amounts.
Customer lifecycle
Decide how XPay treats the customer and which fields the form collects.
After completion
Configure where the customer lands after a successful payment.
Webhooks: setting up an endpoint
Receive checkout.session.completed and verify the signature.
Object model
How XPay's resources fit together. What you create, what you read, and the two IDs to store on your order record.
Line items & pricing
How `lineItems` work on a Checkout Session: existing prices vs inline `priceData`, fixed and adjustable quantity, custom-amount lines, currency, and the availability gate.