docs
IntegrateCheckout Session

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.

A CustomerAPI is XPay's record of someone who paid you. It carries their name, email, phone, address, and the lifetime spend tied to those identifiers. This page is about how that record gets created, prefilled, and re-used across the three integration scenarios you're likely to build: returning authenticated customers, first-time authenticated customers, and guest checkouts.

The single rule that drives every decision below: store the cus_* against your authenticated user, never against a guest. XPay deduplicates guest checkouts server-side; if you pin a cus_* to a guest in your database you'll fight that dedup.

The two customer types

XPay tracks two kinds of Customer records.

TypeWhen it's createdUse
RegisteredEither you set customerCreation: "always" on a Checkout Session and the customer pays, or the merchant explicitly attached a customerId you already had. The dashboard counts them as known customers; you can attach saved payment methods to them later (subscriptions, etc.).Your authenticated user paid. Save the cus_* to their account row.
GuestAuto-created during a checkout when no customerId is provided and customerCreation is if_required (the default). XPay groups guests by email, phone, and card fingerprint so the same person is recognized across visits.A one-time payer or someone who hasn't signed in. Don't store the cus_*: XPay maintains the dedup, you'd just fight it.

Both types appear in the dashboard. The guest's grouping fields (every email and phone they've used, every card they've paid with) build up automatically. The dashboard also surfaces "Related customers" so an operator can see when a guest record overlaps with a registered one.

Three patterns by merchant scenario

The shape of customerCreation, customerId, and customerDetails you send depends on what you know about the person paying.

Returning authenticated user

You have a cus_* stored on your user row. Pass it as customerId. The form prefills from the existing record. Don't pass customerDetails (it's mutually exclusive with customerId). Optionally set customerUpdate to write the form's changes back.

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}" } },
    "customerId": "cus_test_AbC123",
    "customerUpdate": { "address": "auto", "shipping": "auto" },
    "lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
  }'
const session = 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}" },
    },
    customerId: user.xpay_customer_id, // stored on your user row
    customerUpdate: { address: "auto", shipping: "auto" },
    lineItems: [{ price: "price_test_xyz", quantity: 1 }],
  }),
}).then((r) => r.json());
import os, requests

session = 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}"},
        },
        "customerId": user.xpay_customer_id,  # stored on your user row
        "customerUpdate": {"address": "auto", "shipping": "auto"},
        "lineItems": [{"price": "price_test_xyz", "quantity": 1}],
    },
    timeout=10,
).json()

First-time authenticated user

No cus_* yet. Send what you know as customerDetails for prefill, and set customerCreation: "always" so a registered Customer is created at payment time. Capture data.object.customer.id from the checkout.session.completed webhook and store it on your user row.

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}" } },
    "customerCreation": "always",
    "customerDetails": {
      "name": "Aya Hassan",
      "email": "aya@example.com",
      "phone": "+201234567890"
    },
    "metadata": { "user_id": "u_42" },
    "lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
  }'
const session = 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}" },
    },
    customerCreation: "always",
    customerDetails: {
      name: user.name,
      email: user.email,
      phone: user.phone,
    },
    metadata: { user_id: user.id },
    lineItems: [{ price: "price_test_xyz", quantity: 1 }],
  }),
}).then((r) => r.json());
import os, requests

session = 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}"},
        },
        "customerCreation": "always",
        "customerDetails": {
            "name": user.name,
            "email": user.email,
            "phone": user.phone,
        },
        "metadata": {"user_id": user.id},
        "lineItems": [{"price": "price_test_xyz", "quantity": 1}],
    },
    timeout=10,
).json()

Guest checkout

No authenticated user. Omit customerId and leave customerCreation as the default (if_required). XPay creates or matches a guest record by email, phone, and card fingerprint. You don't need to track the cus_* on your side. The dashboard groups guests automatically.

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 } ]
  }'
const session = 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 }],
  }),
}).then((r) => r.json());
import os, requests

session = 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,
).json()

The webhook that fires on success (checkout.session.completed) carries the resolved Customer in data.object.customer. For the first-time-authenticated case, that's where you grab the new cus_* to save on your user row.

customerId vs customerDetails

These are the two mutually-exclusive ways to identify the customer on a session.

FieldWhat it doesRequired type
customerIdReferences an existing Customer (cus_*). The form prefills name, email, phone, billing address, and shipping from that record.Registered
customerDetailsInline contact and address data (name, email, phone, billingDetails.address, shipping). Prefills the form. Used to create a new Customer at payment time.n/a

Sending both is rejected. Sending neither is fine: the form is blank and the customer fills it in.

customerId must point at a registered Customer. Passing a guest's cus_* fails with a clear error. Guests are checkout-only; there's no path to attach them to a future session.

Prefill rules

When the page renders, the form is pre-filled in priority order:

  1. customerDetails.{name, email, phone} if provided.
  2. The existing Customer's name, email, phone, address, shipping if customerId was set.
  3. Empty otherwise. The customer fills it in.

The email and phone fields are locked (read-only) when customerId is set and the existing record carries them. The customer can't change someone else's email or phone mid-checkout.

customerCreation

Controls when XPay creates a Customer record at payment time. Two values; default is if_required.

ValueWhat it does
if_requiredDefault. If the customer fills in an email or phone, a guest Customer is created or matched. If they fill in nothing and the merchant didn't set customerId, no Customer record is created at all.
alwaysA registered Customer is created from customerDetails at payment time, even if they only typed a name. Use this when you want a known account on file (returning visits, post-purchase emails, future subscriptions).

customerCreation is mutually exclusive with customerId: "cus_..." for always. If you already have a Customer, you're not asking XPay to create another one.

customerUpdate

When customerId is set, the form-collected data is not written back to the existing Customer by default. Set customerUpdate to opt-in per field. Each sub-flag is auto (write) or never (default, don't write).

{
  "customerId": "cus_test_AbC123",
  "customerUpdate": {
    "name": "auto",       // overwrite customer.name with the form value
    "address": "auto",    // overwrite customer.address with the billing address
    "shipping": "auto"    // overwrite customer.shipping with the shipping address
  }
}

customerUpdate is only meaningful when customerId is provided. Sending it without customerId is a validation error.

email and phone aren't in customerUpdate because they're locked at the form level when the existing customer carries them. They can only be backfilled when missing (see How XPay handles guest customers).

What the form collects

Four boolean toggles control which fields the hosted checkout form asks the customer to fill. All default to false.

ToggleWhat it asks forRequired at submit?
nameCollectionCustomer's full name.Yes when on.
phoneNumberCollectionCustomer's phone with country code.Yes when on. Submit fails with parameter_missing on customerDetails.phone if blank.
billingAddressCollectionCardholder name and full billing address (street, city, state).Yes when on. Submit fails on missing customerDetails.billingDetails.address.line1.
shippingAddressCollectionRecipient name and shipping address.Yes when on. Submit fails on missing customerDetails.shipping.address.line1.

Email is always asked for and always required. It's the receipt destination and the primary identifier for guest dedup.

The submit button label is set separately by submitType (pay / subscribe / book / donate).

Custom fields

Need to ask for a Tax ID, an order note, or a delivery preference? customFields is an array of up to 3 entries. Each one renders an input on the form and the customer's answer is returned back on the session response and the webhook payload.

Field typeInput renderedValidation
TEXTSingle-line text input.Optional minCharacters / maxCharacters via hasLimits + limitType.
NUMBERNumber-only input.Same character limits apply.
DROPDOWNSelect with merchant-defined dropdownOptions.At least 2 options required.
CHECKBOXSingle checkbox the customer ticks.No character limits.
{
  "customFields": [
    {
      "label": "Tax ID",
      "type": "TEXT",
      "isOptional": false,
      "hasLimits": true,
      "limitType": "BETWEEN",
      "minCharacters": 9,
      "maxCharacters": 15
    },
    {
      "label": "Delivery preference",
      "type": "DROPDOWN",
      "dropdownOptions": [
        { "label": "Leave at door" },
        { "label": "Hand to recipient" }
      ]
    }
  ]
}

Each field gets a stable key server-side (derived from label) so the answer comes back at the same key in the response payload. The collected value lives in the matching text / numeric / dropdown slot on each entry.

How XPay handles guest customers

The default checkout flow (no customerId, customerCreation: "if_required") creates a guest Customer the first time someone checks out. The same guest is matched on subsequent visits when any identifier overlaps. You don't write any code for this.

XPay matches by:

  • Email, case-insensitive and trimmed.
  • Phone, normalized (country code, 00 prefix converted to +).
  • Card fingerprint, captured after a successful charge. If a guest pays with a card that's been seen on another guest record, XPay merges the two and consolidates spend, payment history, and devices on the survivor.

What this gives you in the dashboard:

  • A guest's profile compounds every email, phone, and card they've used over time.
  • A guest's spend, refunds, and payment count are accurate even when they checked out three times with three slightly different forms.
  • The "Related customers" section on a registered customer's profile surfaces the guest records that share contact info with them, so an operator can correlate the two.

What guest customers can't do:

  • They can't have a saved payment method attached for future charges. Every guest checkout is a fresh card entry. If your product needs saved cards (subscriptions, one-click reordering), you must set customerCreation: "always" to create a registered Customer instead.

Avoiding duplicate customer records

The biggest failure mode merchants hit: re-creating an XPay customer on every login or every order, instead of re-using the one you already have. The pattern below avoids that.

Store the cus_* against your authenticated user

When a logged-in user makes their first payment, set customerCreation: "always" and capture data.object.customer.id from the checkout.session.completed webhook. Save that cus_* on your user row in your database.

// In your webhook handler
if (event.type === "checkout.session.completed") {
  const session = event.data.object;
  const userId = session.metadata?.user_id; // you set this on session create
  const customerId = session.customer?.id;  // cus_test_...

  if (userId && customerId) {
    await db.users.update(userId, { xpay_customer_id: customerId });
  }
}

Pass it as customerId on every subsequent session

For every following payment by that same user, set customerId: "cus_test_...". The form prefills, no new Customer is created, and the spend rolls up under the same record.

const session = 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: returnUrl } },
    customerId: user.xpay_customer_id, // re-use, don't recreate
    lineItems: [...],
  }),
});

Don't store guest cus_* on user rows

If a user paid as a guest first (before signing up) and then created an account, the right move is to start the cus_* story on their first registered payment. Don't try to back-fill the guest's cus_* onto their account. Guests dedup server-side; an operator can see the connection in the dashboard's "Related customers" section.

Use metadata to thread your own user ID

Set metadata: { user_id: "..." } on every session you create. It comes back on the webhook payload, on the session retrieve response, and on the Payment Intent. Makes your webhook handler trivial: the session payload tells you exactly which user paid.

The result: one XPay registered Customer per authenticated user, lifetime spend rolls up correctly, and guests are tracked separately without you having to manage them.

Where to next

On this page