Getting Started with the XPay REST API (2026)

A developer's walkthrough of the XPay 3.0 REST API: authentication, Checkout Sessions, webhooks, and your first test transaction in under 30 minutes.

٢٢ يناير ٢٠٢٦11 دقائق قراءة

TL;DR

The XPay REST API is built around a single Checkout Session that powers every integration path. You authenticate with a sk_test_* or sk_live_* Bearer token, you create Checkout Sessions with POST /checkout/sessions, and the session response carries everything else you need: line items, customer, payment intent, charges, balance transactions. The API uses a Stripe-shaped object model that any engineer familiar with Stripe's API will recognize on sight. This guide walks through the smallest end-to-end implementation (authenticating, creating a session, redirecting the customer, verifying the webhook, and issuing a refund) using Node.js, Python, and cURL examples drawn directly from the official docs. By the end you should have a working test-mode payment flow you can ship behind a feature flag in production.

Why This Guide Exists

Most "getting started" guides for payment APIs read like reference documentation with extra paragraphs. This one is structured around what an engineer actually does on day one: get a key, make the first authenticated call, redirect a customer, handle the webhook, and issue a refund. Everything else (line items with custom pricing, embedded checkout, recurring billing, fee splits) sits on top of that.

If you're moving an existing integration onto the XPay 3.0 API, the architectural shift to notice is that the entire surface now flows through a Checkout Session. Older integrations that used direct transaction endpoints continue to work for read access but new payment flows should go through POST /checkout/sessions. The object model documented at docs.xpay.app/integrate/object-model maps cleanly onto the Stripe model (Checkout Session, Payment Intent, Charge, Refund, Customer, Balance Transaction), and the integration paths (Hosted Checkout, Drop-in, Elements, Payment Links) are surfaces over the same underlying objects.

Authentication

Every API call carries an HTTP Bearer token in the Authorization header. There are two kinds of secret key:

sk_test_* for test mode: every request runs against XPay's sandbox processor, no real money moves, and you can create unlimited sessions.

sk_live_* for live mode: same API, real money. Live keys are issued after your account completes merchant onboarding.

Find both in the dashboard under Developer → API Keys. The base URL is https://api.xpay.app in both modes; whether a request lands in test or live is determined entirely by which key signed it. Sessions created by a test key produce cs_test_* IDs; live sessions produce cs_live_* IDs. Never put a secret key in client-side code or commit it to a repository: the secret key is a server-only credential.

For Drop-in and Elements integrations, you'll also use a publishable key (pk_test_* / pk_live_*), which loads the JavaScript SDK in the browser. Publishable keys are safe to expose; they can't create sessions or move money on their own.

For server-to-server access where you want to limit blast radius, XPay also issues restricted keys (rk_test_* / rk_live_*): scoped credentials with per-resource permissions drawn from a 54-permission model (read/write on charges, refunds, payouts, customers, payment intents, reporting, and more). Hand a restricted key to a background job, a third-party tool, or a teammate's script so it can do exactly what it needs and nothing else. Manage scopes in the dashboard under Developer → API Keys.

A first authenticated request just to confirm everything's wired up:

curl https://api.xpay.app/checkout/sessions \
  -H "Authorization: Bearer sk_test_..."

This returns a paginated list of any sessions you've already created (empty array on a fresh account). A 401 here means your key is wrong; a 200 with data: [] means you're connected.

The Object Model in 30 Seconds

Six objects make up the entire transactional surface. You create two of them and read the rest as nested fields.

Checkout Session (cs_*): the configured checkout. Line items, customer fields, integration surface. One per customer attempt. You create this with POST /checkout/sessions.

Customer (cus_*): the shopper's record. Captured during checkout or linked by customerId if you already have one.

Payment Intent (pi_*): the transaction. What money is meant to move, the method used, the state it's in. Created automatically when the customer submits the form. This is the ID you store on your order record to issue refunds later.

Charge (ch_*): a money-move attempt. One try to authorize and capture on the customer's payment method. A Payment Intent has more than one if the customer retried.

Refund (re_*): a money reversal. You create this with POST /refunds.

Balance Transaction (txn_*): a ledger row. Every Charge and Refund produces one. The signed value is what hits your balance and reconciles against payouts.

The graph: Checkout Session → Customer + Payment Intent → Charges → Balance Transactions, plus optional Refunds → Balance Transactions. You can pull the whole graph from a single GET /checkout/sessions/:id call. The two IDs worth keeping on your own order record are the cs_* (your checkout reference) and the pi_* (your transaction handle for refunds and reconciliation). Full reference at docs.xpay.app/integrate/object-model.

Build a First Payment (Hosted Checkout)

The shortest path to a working integration is Hosted Checkout: your server creates a session, you redirect the customer to XPay's hosted page, and your webhook receives the result. No frontend code required.

1. Create a Checkout Session

The only required field is afterCompletion.redirect.url telling XPay where to send the customer after they pay. In practice you'll also send lineItems so XPay knows 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
      }
    ]
  }'

Node.js equivalent using fetch:

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();

Python with requests:

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

Three things worth noting. unitAmount is in minor units: 149900 is 1,499.00 EGP, not 149,900. {CHECKOUT_SESSION_ID} in the redirect URL is replaced server-side with the session's actual ID, so your return page can read it off the path without threading state through your app. And the session expires after 24 hours if the customer doesn't pay; you can also expire it manually with POST /checkout/sessions/:id/expire.

The response includes the session ID, the hosted checkout URL, and the configured amounts:

{
  "id": "cs_test_AbC123...",
  "object": "checkout.session",
  "status": "open",
  "url": "https://checkout.xpay.app/c/cs_test_AbC123...",
  "amountTotal": 149900,
  "currency": "EGP"
}

2. Redirect the Customer

Send an HTTP 303 (or 302 from a GET) to session.url:

app.post("/start-checkout", async (req, res) => {
  const session = await createCheckoutSession(req.body);
  res.redirect(303, session.url);
});

The customer lands on https://checkout.xpay.app/c/cs_test_..., fills in their card, completes 3D Secure if required, and is redirected back to your afterCompletion.redirect.url. The return page is purely customer-facing: your server doesn't trust it to confirm payment.

3. Verify the Webhook

The source of truth for payment success is the checkout.session.completed webhook. XPay signs every delivery with HMAC-SHA256 using the endpoint's whsec_* signing secret, and you recompute the signature on receipt to confirm the request is authentic. The full signature verification recipe lives at docs.xpay.app/integrate/webhooks/verifying-signatures; the companion webhook best practices guide walks through it in depth.

A minimal handler in Express:

import crypto from "node:crypto";

app.post(
  "/webhooks/xpay",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.header("XPay-Signature");
    const rawBody = req.body.toString("utf8");
    const [t, v1] = header.split(",").map(p => p.split("=")[1]);

    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
      return res.status(400).send("stale");
    }

    const expected = crypto
      .createHmac("sha256", process.env.XPAY_WEBHOOK_SECRET)
      .update(`${t}.${rawBody}`)
      .digest("hex");

    if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
      return res.status(400).send("bad sig");
    }

    const event = JSON.parse(rawBody);
    if (event.type === "checkout.session.completed" &&
        event.data.object.status === "complete") {
      fulfillOrder(event.data.object); // must be idempotent on session.id
    }
    res.status(200).send();
  },
);

The framework-specific raw-body wiring is where most signature failures originate: Express's default JSON parser will re-serialize the body before your handler sees it, and the HMAC won't match. Use express.raw({ type: "application/json" }) on this exact route, before any global JSON middleware. Equivalent recipes for Fastify and Next.js App Router are in the signature verification docs.

Issuing a Refund

One API call against the pi_* you stored on the order record. Omit amount to refund the full remaining refundable amount; include it for partial refunds in minor units.

curl -X POST https://api.xpay.app/refunds \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "paymentIntentId": "pi_test_xyz789",
    "amount": 50000,
    "reason": "requested_by_customer",
    "metadata": { "order_id": "ord_42" }
  }'

The response carries both chargeId and paymentIntentId plus a balanceTransactionId pointing to the ledger row that debited your available balance. Card refunds typically appear on the customer's statement in 7 to 14 days: tell customers in the refund email so they don't assume it failed.

Multiple partial refunds against the same Charge are supported as long as the cumulative amount doesn't exceed the original. Each call creates its own re_* and its own Balance Transaction. Full reference at docs.xpay.app/integrate/refunds.

Idempotency, Errors, and the Workbench

Three things to know before you ship.

Idempotency on creation. Send an Idempotency-Key header on POST /checkout/sessions (and POST /refunds) and XPay guarantees the request runs at most once: a retry with the same key replays the original stored response instead of opening a second session, and the same key reused with a different payload returns 409 idempotency_key_in_use. This is a full request-level idempotency layer at Stripe parity, so a timed-out create is always safe to retry as long as you reuse the key. (If you don't send one, fall back to storing the session ID from the first successful response and looking it up before retrying.)

Idempotency on webhooks. XPay retries deliveries on every non-2xx and timeout, with exponential backoff: up to 5 attempts over ~2.5 hours in test mode, and up to 13 attempts over ~3 days in live mode (a live endpoint that never succeeds is auto-disabled, with an alert). The same checkout.session.completed for the same session can arrive more than once. Dedupe on the top-level event.id in your handler.

Errors. API errors come back with stable error codes paired with deep-link documentation URLs. Validation errors return 400 with the failing field, authentication errors return 401, rate-limiting returns 429 with a Retry-After header. The full error taxonomy is at docs.xpay.app/integrate/errors.

The Workbench. When something doesn't behave as expected (a session that won't complete, a webhook that's not arriving, a refund that's stuck) the XPay Workbench is the first place to look. The Inspector takes any XPay ID and shows the full graph of related resources, events, and request logs. Per-request logs filter every API call by error code and request ID. Webhook replay lets you resend any past delivery without re-triggering the original payment. Health monitoring groups failures by root cause. The cumulative effect is that the typical "failed payment investigation" drops from thirty minutes to about thirty seconds.

Going Live

When you're ready to flip from test to live, the surface area to change is small. Swap sk_test_* for sk_live_* on your server; the API URL doesn't change. Create a separate live webhook endpoint in the dashboard (test and live endpoints are isolated) and store the new whsec_* secret. Verify the https:// redirect URL on a domain you control (no localhost). And confirm that signature verification, idempotency keys, and error handling all behave correctly in production traffic.

A working production checklist sits at the bottom of each integration pattern's documentation page: Hosted Checkout, Drop-in, Elements. Run through whichever applies to your integration before flipping the switch.

Final Thoughts

The XPay 3.0 REST API is intentionally small. One creation endpoint for sessions, one creation endpoint for refunds, a handful of read endpoints, and a webhook event stream that mirrors the same object model. The architectural decision worth understanding upfront is that the Checkout Session is the unifying object: every integration path (Payment Links, Hosted Checkout, Drop-in, Elements) is a surface over the same underlying API and emits the same checkout.session.completed webhook.

For engineering teams used to the Stripe pattern, that translation is immediate. For teams new to it, the object model overview is the page worth reading first. Everything else in the API follows from there.

You can take a first test payment in five minutes at app.xpay.app: no code required.

Sources

ابدأ قبول المدفوعات

أنشئ حسابك في XPay واقبل البطاقات ووسائل الدفع المحلية، مع التسوية إلى حسابك البنكي بالجنيه المصري.