<!-- ZenPays documentation · https://docs.zenpayz.com/docs/guides/custom-checkout-integration -->

# Custom Checkout Integration

Build your own checkout page using ZenPays APIs. Instead of redirecting customers to the ZenPays hosted checkout, you can collect payment details on your own page and confirm the payment via API.

## Flow Overview

```
1. Your Server          2. Your Checkout Page        3. Your Checkout Page        4. Customer Browser
   Create Intent  ──►      Fetch Intent Details  ──►    Confirm Payment     ──►    Redirect to TSP
   (server-side)           (client-side)                (client-side)              (payment page)
```

**Step-by-step:**

1. **Create Payment Intent** (server-side) — Your backend creates a payment intent via API with the amount, currency, and customer info.
2. **Fetch Payment Intent** (client-side) — Your checkout page fetches the intent details to get supported payment methods, countries, and channel limits.
3. **Confirm Payment** (client-side) — After the customer selects a payment method and fills in details, your page sends a confirm request.
4. **Redirect** — The confirm response returns a `redirectUrl`. Redirect the customer there to complete payment with the TSP (payment provider).
5. **Webhook** — ZenPays sends a webhook to your server when the payment succeeds or fails.

---

## Base URL

```
https://api.zenpayz.com/payment/api/v1
```

---

## 1. Create Payment Intent

Creates a payment intent on your server. This should be called from your **backend** (not the browser) since it requires your Merchant ID.

### Request

```
POST /payment/api/v1/payment-intent
```

**Headers:**

| Header | Required | Description |
|--------|----------|-------------|
| `X-Merchant-ID` | Yes | Your merchant ID |
| `Content-Type` | Yes | `application/json` |
| `X-Request-ID` | No | Unique request ID for idempotency |

**Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount` | number | Yes | Payment amount (minimum: 0.01) |
| `currency` | string | Yes | Currency code (e.g. `INR`, `USD`, `BRL`) |
| `customerEmail` | string | Yes* | Customer email (*required if no `customerId`) |
| `customerPhone` | string | No | Customer phone number |
| `customerFirstName` | string | No | Customer first name |
| `customerLastName` | string | No | Customer last name |
| `customerCountry` | string | No | ISO 3166-1 alpha-2 country code (e.g. `IN`) |
| `customerId` | string | No | Existing customer ID (skip email if provided) |
| `paymentMethod` | string | No | Preferred payment method |
| `description` | string | No | Payment description (max 500 chars) |
| `returnUrl` | string | No | URL to redirect customer after payment |
| `metadata` | object | No | Custom key-value data |
| `requestId` | string | No | Unique request ID (10-64 chars) |

### Example Request

```json
{
  "amount": 500,
  "currency": "INR",
  "customerEmail": "customer@example.com",
  "customerPhone": "+911234567890",
  "customerFirstName": "John",
  "customerLastName": "Doe",
  "customerCountry": "IN",
  "description": "Order #12345",
  "returnUrl": "https://yoursite.com/payment-complete",
  "metadata": {
    "orderId": "order_12345"
  }
}
```

### Example Response

```json
{
  "success": true,
  "data": {
    "intentId": "pi_1775052514718_yn6v3nc47",
    "merchantId": "ZP_FIN_1774592804_0781001264",
    "amount": 500,
    "currency": "INR",
    "status": "requires_payment_method",
    "description": "Order #12345",
    "expiresAt": "2026-04-01T11:00:04.296Z",
    "processingFee": 0,
    "createdAt": "2026-04-01T10:50:04.299Z",
    "paymentPageUrl": "https://pay.zenpays.com/pay/pi_1775052514718_yn6v3nc47?token=pi_1775052514718_secret_abc123"
  },
  "message": "Payment intent created successfully",
  "error": null,
  "meta": {
    "request_id": "req_1775052514718_abc123",
    "timestamp": "2026-04-01T10:50:04.500Z",
    "processing_time_ms": 200,
    "api_version": "v1",
    "endpoint": "POST /payment-intent"
  }
}
```

Save the `intentId` — you'll need it for the next steps.

---

## 2. Fetch Payment Intent (Public)

Fetch the payment intent details on your checkout page. This is a **public endpoint** — no authentication required. Use this to render supported payment methods and validate amounts.

### Request

```
GET /payment/api/v1/payment-intent/public/{intentId}
```

No headers required.

### Example Response

```json
{
  "success": true,
  "data": {
    "intentId": "pi_1775052514718_yn6v3nc47",
    "merchantId": "ZP_FIN_1774592804_0781001264",
    "merchantName": "Your Store",
    "merchantLogo": "https://cdn.example.com/logo.png",
    "amount": "500.00000000",
    "currency": "INR",
    "description": "Order #12345",
    "status": "requires_payment_method",
    "expiresAt": "2026-04-01T11:00:04.296Z",
    "isExpired": false,
    "timeRemaining": 598,
    "customerEmail": "customer@example.com",
    "customerPhone": "+911234567890",
    "supportedCountries": ["IN", "BR", "HK"],
    "supportedPaymentMethods": ["UPI", "UPIQR-H5", "BANK_TRANSFER"],
    "channelLimits": {
      "INR": {
        "UPI": { "min": 100, "max": 50000 },
        "UPIQR-H5": { "min": 500, "max": 50000 }
      }
    },
    "selectedTsp": "sulifu_pay",
    "processingFee": 0,
    "createdAt": "2026-04-01T10:50:04.299Z"
  },
  "message": "Payment intent details retrieved successfully",
  "error": null,
  "meta": {
    "request_id": "req_1775052515000_xyz789",
    "timestamp": "2026-04-01T10:50:05.000Z",
    "processing_time_ms": 372,
    "api_version": "v1",
    "endpoint": "GET /payment-intent/public/pi_1775052514718_yn6v3nc47",
    "user_type": "merchant"
  }
}
```

### Key Fields for Your Checkout UI

| Field | How to Use |
|-------|------------|
| `supportedPaymentMethods` | Render only these payment methods as options. Values like `"UPI"`, `"UPIQR-H5"`, `"BANK_TRANSFER"`, `"CARD"`, `"PIX"`. |
| `channelLimits` | Validate the payment amount against min/max per method. If the amount is outside the range, disable or hide that method. |
| `isExpired` / `timeRemaining` | If `isExpired` is `true`, show an error — the intent can no longer be paid. `timeRemaining` is in seconds. |
| `status` | Must be `"requires_payment_method"` to proceed. Any other status means the intent is already processing or completed. |
| `amount` / `currency` | Display the payment amount to the customer. |
| `merchantName` / `merchantLogo` | Display merchant branding on your checkout page. |

### Edge Cases to Handle

- **Expired intent**: If `isExpired` is `true`, show "Payment link expired" and don't allow confirmation.
- **Already paid**: If `status` is `"succeeded"` or `"processing"`, show the appropriate message.
- **Amount below channel limit**: If `channelLimits.INR.UPI.min` is 100 and the amount is 50, don't show UPI as an option.
- **UPI methods only for INR**: `UPI` and `UPIQR-H5` are only valid when currency is `INR`. Don't show them for other currencies.
- **No supported methods**: If `supportedPaymentMethods` is empty or none pass the channel limits filter, show an error.

---

## 3. Confirm Payment Intent

After the customer selects a payment method and fills in details, confirm the payment. This is a **public endpoint**.

### Request

```
POST /payment/api/v1/payment-intent/{intentId}/confirm
```

**Headers:**

| Header | Required | Description |
|--------|----------|-------------|
| `Content-Type` | Yes | `application/json` |

### Request Body Structure

```json
{
  "customerDetails": {
    "name": "Customer Name",
    "email": "customer@example.com",
    "phone": "+911234567890",
    "address": {
      "country": "IN"
    }
  },
  "paymentMethodDetails": {
    "paymentMethod": "FIAT",
    "paymentType": "UPI"
  },
  "deviceInfo": {
    "userAgent": "Mozilla/5.0 ...",
    "browserInfo": "Mozilla/5.0 ...",
    "screenWidth": 1512,
    "screenHeight": 982,
    "deviceFingerprint": "unique_fingerprint_hash",
    "metadata": {
      "selectedPaymentMethod": "fiat",
      "selectedMethod": "upi",
      "specificPaymentMethod": "UPI",
      "customerCurrency": "INR"
    }
  },
  "confirmSource": "MERCHANT_SITE"
}
```

### Field Reference

**customerDetails** (required):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Full customer name |
| `email` | string | Yes | Customer email |
| `phone` | string | No | Customer phone |
| `address.country` | string | No | ISO country code |

**paymentMethodDetails** (required):

| Field | Type | Description |
|-------|------|-------------|
| `paymentMethod` | string | Always `"FIAT"` for fiat payments |
| `paymentType` | string | The payment type (see table below) |

**confirmSource** (required for custom checkout):

Always set to `"MERCHANT_SITE"`. The value `"ZENPAY_CHECKOUT"` is reserved for the ZenPays hosted checkout page.

---

## Payment Method Examples

### UPI Payment

```json
{
  "paymentMethodDetails": {
    "paymentMethod": "FIAT",
    "paymentType": "UPI",
    "upiId": ""
  }
}
```

The `upiId` field can be empty — the TSP payment page will collect it.

### UPIQR-H5 Payment (QR Code)

```json
{
  "paymentMethodDetails": {
    "paymentMethod": "FIAT",
    "paymentType": "UPIQR-H5"
  }
}
```

No additional fields needed. The TSP generates the QR code on their payment page.

### Bank Transfer

```json
{
  "paymentMethodDetails": {
    "paymentMethod": "FIAT",
    "paymentType": "BANK_TRANSFER",
    "bankCode": "dp_hdfcbank_in"
  }
}
```

### Card Payment

```json
{
  "paymentMethodDetails": {
    "paymentMethod": "FIAT",
    "paymentType": "CARD",
    "cardNumber": "4111111111111111",
    "expiryMonth": "12",
    "expiryYear": "2027",
    "cvv": "123",
    "cardHolderName": "John Doe"
  }
}
```

### PIX (Brazil)

```json
{
  "paymentMethodDetails": {
    "paymentMethod": "FIAT",
    "paymentType": "PIX"
  }
}
```

---

## Payment Type Reference

| paymentType | Currency | Extra Fields |
|-------------|----------|--------------|
| `UPI` | INR | `upiId` (optional) |
| `UPIQR-H5` | INR | None |
| `BANK_TRANSFER` | INR | `bankCode` (optional) |
| `CARD` | Any | `cardNumber`, `expiryMonth`, `expiryYear`, `cvv`, `cardHolderName` |
| `PIX` | BRL | None |
| `NETBANKING` | INR | `bankCode` (optional) |
| `WALLET` | INR | `walletProvider` (optional) |

---

## Confirm Response

### Example (UPI)

```json
{
  "success": true,
  "data": {
    "intentId": "pi_1775052514718_yn6v3nc47",
    "redirectUrl": "https://cashier.ppco.lol?orderNo=C2026040119435142093857",
    "tspProvider": "sulifu_pay",
    "externalTransactionId": "zpd6a82e78cedc1775052830530",
    "status": "created",
    "message": "Payment confirmed successfully. Redirecting to TSP.",
    "requiresCardDetails": false,
    "supportedCurrencies": ["INR"],
    "supportedCountries": ["IN", "BR", "HK"],
    "transactionId": "txn_1775052514718_1_mng4mhqh_p9axu5"
  },
  "message": "Payment confirmation successful",
  "error": null,
  "meta": {
    "request_id": "req_1775052830084_b0c0htr5e",
    "timestamp": "2026-04-01T14:13:51.654Z",
    "processing_time_ms": 1569,
    "api_version": "v1",
    "endpoint": "POST /payment-intent/pi_1775052514718_yn6v3nc47/confirm",
    "user_type": "merchant"
  }
}
```

### What to Do with the Response

1. **Check `success`** — If `false`, show the error message to the customer.
2. **Redirect** — If `redirectUrl` is present, redirect the customer's browser to that URL. This is the TSP's payment page where the customer completes the payment (enters UPI PIN, scans QR code, etc.).
3. **Save `transactionId`** — Store this for tracking and reconciliation.

```javascript
const response = await fetch(`/payment/api/v1/payment-intent/${intentId}/confirm`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(confirmPayload),
});

const result = await response.json();

if (result.success && result.data.redirectUrl) {
  // Redirect customer to TSP payment page
  window.location.href = result.data.redirectUrl;
} else {
  // Show error
  alert(result.data?.message || result.error?.message || 'Payment failed');
}
```

---

## Edge Cases and Error Handling

### Payment Intent Errors

| Scenario | How to Detect | What to Show |
|----------|--------------|--------------|
| Intent expired | `isExpired: true` from GET response | "This payment link has expired. Please request a new one." |
| Intent already paid | `status: "succeeded"` | "This payment has already been completed." |
| Intent processing | `status: "processing"` | "This payment is currently being processed." |
| Intent not found | 404 from GET endpoint | "Payment not found." |

### Confirm Errors

| Scenario | How to Detect | What to Show |
|----------|--------------|--------------|
| Amount below minimum | Error response with min amount message | "Minimum amount for UPI is 100 INR." |
| Amount above maximum | Error response with max amount message | "Maximum amount for UPI is 50,000 INR." |
| Invalid payment method | 400 error | "Selected payment method is not supported." |
| TSP unavailable | 500 error with routing failure | "Payment service temporarily unavailable. Please try again." |

### Channel Limits Validation

Before showing payment methods to the customer, filter out methods where the amount is outside the channel limits:

```javascript
const intentData = await fetchPaymentIntent(intentId);
const { amount, currency, supportedPaymentMethods, channelLimits } = intentData;

const currencyLimits = channelLimits?.[currency.toUpperCase()];

const availableMethods = supportedPaymentMethods.filter(method => {
  if (!currencyLimits) return true;
  const limit = currencyLimits[method];
  if (!limit) return true;
  return amount >= limit.min && amount <= limit.max;
});

// Render only `availableMethods` as options
```

### UPI is INR-Only

`UPI` and `UPIQR-H5` payment methods are only valid when the currency is `INR`. Don't show them for other currencies:

```javascript
const UPI_METHODS = ['UPI', 'UPIQR-H5'];
const isINR = currency.toUpperCase() === 'INR';

const filteredMethods = availableMethods.filter(method => {
  if (UPI_METHODS.includes(method) && !isINR) return false;
  return true;
});
```

---

## Webhooks

After the customer completes payment on the TSP page, ZenPays sends a webhook to your configured endpoint with the payment result.

See the [Webhooks Guide](./webhooks/index.md) for setup instructions and payload format.

---

## Full Integration Example

```javascript
// 1. Server-side: Create payment intent
const createResponse = await fetch('https://api.zenpayz.com/payment/api/v1/payment-intent', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Merchant-ID': 'your_merchant_id',
  },
  body: JSON.stringify({
    amount: 500,
    currency: 'INR',
    customerEmail: 'customer@example.com',
    returnUrl: 'https://yoursite.com/payment-complete',
  }),
});
const { data: { intentId } } = await createResponse.json();

// 2. Client-side: Fetch intent details
const intentResponse = await fetch(
  `https://api.zenpayz.com/payment/api/v1/payment-intent/public/${intentId}`
);
const { data: intent } = await intentResponse.json();

if (intent.isExpired) {
  showError('Payment link expired');
  return;
}

// 3. Client-side: Render payment methods filtered by channel limits
const methods = filterMethodsByLimits(
  intent.supportedPaymentMethods,
  intent.channelLimits,
  intent.currency,
  intent.amount
);

// 4. Client-side: After customer selects UPI and submits
const confirmResponse = await fetch(
  `https://api.zenpayz.com/payment/api/v1/payment-intent/${intentId}/confirm`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      customerDetails: {
        name: 'John Doe',
        email: 'customer@example.com',
        phone: '+911234567890',
        address: { country: 'IN' },
      },
      paymentMethodDetails: {
        paymentMethod: 'FIAT',
        paymentType: 'UPI',
        upiId: '',
      },
      deviceInfo: {
        userAgent: navigator.userAgent,
        screenWidth: window.screen.width,
        screenHeight: window.screen.height,
      },
      confirmSource: 'MERCHANT_SITE',
    }),
  }
);

const result = await confirmResponse.json();

// 5. Redirect to TSP payment page
if (result.success && result.data.redirectUrl) {
  window.location.href = result.data.redirectUrl;
}
```

---

## UPI Native Link Handling (INR Payments)

For INR payments, the `redirectUrl` opens a TSP payment page that shows a QR code. On mobile, customers can't scan a QR code on the same device they're paying from. ZenPays provides tools to **extract the `upi://` deep link** from the QR code so you can:

- **Mobile**: Open the customer's UPI app directly (GPay, PhonePe, Paytm) with one tap
- **Desktop**: Show your own clean QR code + "Copy UPI Link" button

### Quick Start: Poll ZenPays API

After confirming payment, poll the ZenPays API to get the extracted UPI link:

```javascript
// After confirm returns redirectUrl for INR payment:
if (result.data.redirectUrl && currency === 'INR') {
  const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);

  // Extract cashierOrderNo from redirectUrl for faster lookup
  let cashierOrderNo = '';
  try {
    cashierOrderNo = new URL(result.data.redirectUrl).searchParams.get('orderNo') || '';
  } catch {}

  // Poll every 4 seconds (ZenPays extracts the QR server-side)
  const interval = setInterval(async () => {
    const params = new URLSearchParams();
    if (cashierOrderNo) params.set('cashierOrderNo', cashierOrderNo);
    params.set('payPageUrl', result.data.redirectUrl);

    const res = await fetch(
      `https://api.zenpayz.com/payment/api/v1/payment-intent/${intentId}/upi-link?${params}`
    );
    const data = await res.json();

    if (data.success && data.data?.upiDeepLink) {
      clearInterval(interval);
      const upiLink = data.data.upiDeepLink;

      if (isMobile && upiLink.startsWith('upi://')) {
        // Mobile: open UPI app with brief delay
        setTimeout(() => { window.location.href = upiLink; }, 600);
      } else {
        // Desktop: show your own QR + copy button (don't redirect)
        showDesktopUpiUI(upiLink);
      }
    }
  }, 4000);

  // Stop polling after 60 seconds
  setTimeout(() => clearInterval(interval), 60000);
}
```

### Full Guide

For complete implementation with multiple approaches (server-side headless browser extraction, frontend screen capture), code examples in JavaScript, Python, and PHP, and best practices, see the **[UPI Native Link Guide](./upi-native-link-guide.md)**.
