docs
IntegrateCheckout Session

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.

Lifecycle

A session moves through two short state machines: status for the session itself and paymentStatus for the money.

status

StateWhen it's setWhat you do
openSet 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.
completeSet when payment succeeds. The session is locked: no more mutations, no more payment attempts.Fulfill the order against checkout.session.completed.
expiredSet 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

StateWhen it's set
unpaidDefault. No successful charge has occurred.
paidA charge has succeeded for the session's full amount.
no_payment_requiredThe 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.

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.

ValueMeaning
paymentOne-time charge. The default for nearly every integration.
subscriptionRecurring billing. Not yet supported on the checkout API.
setupCollect 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.

ValueWhere the form runsUse it with
hostedXPay's hosted page at https://checkout.xpay.app/c/cs_test_.... Server returns a url.Hosted Checkout, Payment Links.
embeddedAn iframe on your site, opened by the SDK as a modal or inline.Drop-in.
customYour own form, using the Elements SDK and confirmPayment().Elements.

A few rules fall out of this:

  • cancelUrl is only valid when uiMode: "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 rejects nameCollection, phoneNumberCollection, billingAddressCollection, shippingAddressCollection, and submitType in this mode.
  • For embedded and custom, you authenticate the SDK with the session's clientSecret. 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.

EitherOrWhy
customerIdcustomerDetailsAn existing CustomerAPI's record is the source of prefill, or you provide raw details. Not both.
customerIdcustomerCreation: "always""Always create a new customer" contradicts "use this existing customer."
paymentMethodTypespaymentMethodConfigurationIdPick a literal list of methods, or reference a saved configuration.
lineItem.pricelineItem.priceDataEach line item references an existing PriceAPI by id, or it inlines product and price data.
A CUSTOM-type lineallowPromotionCodes, discountsCustom-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:

  • mode
  • uiMode
  • submitType
  • currency
  • expiresAfterMinutes

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:

  • expiresAt passes. 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.

EventWhen
checkout.session.completedPayment succeeded. status is complete, paymentStatus is paid (or no_payment_required for setup mode).
checkout.session.expiredThe 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:

  1. payment_intent.created
  2. charge.succeeded
  3. payment_intent.succeeded
  4. checkout.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

On this page