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:
- Create Payment Intent (server-side) — Your backend creates a payment intent via API with the amount, currency, and customer info.
- Fetch Payment Intent (client-side) — Your checkout page fetches the intent details to get supported payment methods, countries, and channel limits.
- Confirm Payment (client-side) — After the customer selects a payment method and fills in details, your page sends a confirm request.
- Redirect — The confirm response returns a
redirectUrl. Redirect the customer there to complete payment with the TSP (payment provider). - 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
{
"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
{
"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
{
"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
isExpiredistrue, show "Payment link expired" and don't allow confirmation. - Already paid: If
statusis"succeeded"or"processing", show the appropriate message. - Amount below channel limit: If
channelLimits.INR.UPI.minis 100 and the amount is 50, don't show UPI as an option. - UPI methods only for INR:
UPIandUPIQR-H5are only valid when currency isINR. Don't show them for other currencies. - No supported methods: If
supportedPaymentMethodsis 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
{
"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
{
"paymentMethodDetails": {
"paymentMethod": "FIAT",
"paymentType": "UPI",
"upiId": ""
}
}
The upiId field can be empty — the TSP payment page will collect it.
UPIQR-H5 Payment (QR Code)
{
"paymentMethodDetails": {
"paymentMethod": "FIAT",
"paymentType": "UPIQR-H5"
}
}
No additional fields needed. The TSP generates the QR code on their payment page.
Bank Transfer
{
"paymentMethodDetails": {
"paymentMethod": "FIAT",
"paymentType": "BANK_TRANSFER",
"bankCode": "dp_hdfcbank_in"
}
}
Card Payment
{
"paymentMethodDetails": {
"paymentMethod": "FIAT",
"paymentType": "CARD",
"cardNumber": "4111111111111111",
"expiryMonth": "12",
"expiryYear": "2027",
"cvv": "123",
"cardHolderName": "John Doe"
}
}
PIX (Brazil)
{
"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)
{
"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
- Check
success— Iffalse, show the error message to the customer. - Redirect — If
redirectUrlis 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.). - Save
transactionId— Store this for tracking and reconciliation.
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:
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:
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 for setup instructions and payload format.
Full Integration Example
// 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:
// 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.