This applies to human developers and to any AI assistant (Claude, ChatGPT, Copilot, etc.) integrating ZapAPI on someone's behalf. Skipping this section is the single biggest cause of a broken first integration.
console.error and never calls onFailed or onTimeout. If you build against the widget without independently testing it in a real browser, you can ship a page that looks correct in code review and still shows nothing but "Validation failed" in production.window.ZapPay undefined or partially ready with no error surfaced to your code. If you use the widget, verify typeof ZapPay !== 'undefined' before calling it, and test the actual click end-to-end — do not assume it works because the sample code matches the docs.amount and customer_mobile before every request. amount must be a plain number (499, not "₹499" or "1,999"). customer_mobile, if sent, must be exactly 10 digits, optionally prefixed with 91 / +91 / a leading 0, no spaces or symbols. These two are responsible for nearly every "Validation failed" error reported against this API.GET /api/developer/order-status/:orderId and reading status === "success". This is repeated throughout this document because integrations that skip it ship real payment bugs, not cosmetic ones.fetch()-based REST flow in Section 4 — it has no hidden failure mode.
ZapAPI lets you create UPI payment links, drop in a one-line JS widget, and confirm payments server-side — built for Indian merchants shipping fast.
Every ZapPay account has exactly one ZapAPI key — a 16-character secret that authenticates every request you make to ZapAPI. It's the only credential you need.
Treat your ZapAPI key like a password:
Your ZapAPI key is generated automatically the first time you open the developer portal:
You can regenerate your key at any time from the same screen. Regenerating instantly invalidates the old key — any integration still using it will start receiving 401 Invalid ZapAPI key until you update it.
Every ZapPay integration — widget or raw API — follows the same three steps:
POST /api/developer/create-order with an amount. You get back a payment_url.payment_url — as a redirect, a popup, or inside a WebView — where they complete payment via any UPI app.GET /api/developer/order-status/:orderId and check that status is "success" before you fulfill the order.redirect_url, or a client-side JS callback firing, is never proof of payment on its own. Both can be spoofed, retried, or interrupted. Always call order-status from your server and check status === "success" before marking anything as paid.https://zappay-beta.vercel.app
Every request must include your ZapAPI key, passed one of two ways — the API accepts either:
zap_api in the JSON request body, orX-ZapAPI-Key| Situation | Response |
|---|---|
| Key missing entirely | 401 — Missing ZapAPI key... |
| Key is invalid | 401 — Invalid ZapAPI key |
| Account is suspended | 403 — This account has been suspended. |
{
"success": false,
"message": "Missing ZapAPI key. Pass it as \"zap_api\" in the request body or an \"X-ZapAPI-Key\" header."
}
{
"success": false,
"message": "This account has been suspended.",
"banned": true
}
60 requests per 15 minutes, bucketed per ZapAPI key (falls back to your IP address if no key is found on the request). Exceeding it returns:
{
"success": false,
"message": "Too many API requests. Please try again later."
}
Creates a new UPI payment order and returns a payment_url to send your customer to. Requires header Content-Type: application/json.
| Field | Required | Type | Notes |
|---|---|---|---|
zap_api |
Yes* | string | *Only if not using the X-ZapAPI-Key header instead. |
amount |
Yes | number | ₹1 to ₹5,000. Must be a plain number — 499, not "₹499", "4,999", or any string with a currency symbol/commas. If you're pulling this from a product price that you display formatted (e.g. "₹499"), strip the symbol and commas and pass a clean number/numeric string — a formatted value fails the same isFloat check and returns Validation failed below. |
title |
No | string | Max 100 chars. Shown as the payment remark. Defaults to "ZapPay Payment". |
customer_mobile |
No | string | If provided, must match exactly 10 digits starting with 6/7/8/9, optionally preceded by 91, +91, or a leading 0 — no spaces, dashes, or brackets (e.g. "+91 98765 43210" as typed by a user will fail; strip it to "9876543210" first). Prefills the customer's UPI app. If you're capturing this from a real input field, either strip all non-digits before sending or omit the field entirely — it's optional. |
redirect_url |
No | string | Where the customer lands after paying. ZapPay appends ?zp_order=<id>&zp_result=success|failed automatically (using & if your URL already has a ?). Ignored if useEmbedded: true. |
useEmbedded |
No | boolean | Set true for the JS widget / in-app popup flow. No server redirect URL is generated in that case. |
amount as a formatted price string instead of a clean number. If your product price is stored/displayed as "₹499" or "1,999" and you pass that straight through, isFloat rejects it and you get exactly {"success":false,"message":"Validation failed"} — with no field name shown unless you also log errors. Always do Number(String(price).replace(/[^0-9.]/g, '')) (or equivalent) before calling create-order. The second most common cause is customer_mobile containing spaces/dashes/a country code with a space — see the format rule in the table above.400 asking you to upgrade or withdraw), and platform maintenance mode (a temporary 503).{
"success": true,
"message": "Order created",
"data": {
"order_id": "ZP1782...ABCD",
"payment_url": "https://pay.zapupi.com/...",
"amount": 100
}
}
{
"success": false,
"message": "Validation failed",
"errors": [
{ "msg": "Amount must be between ₹1 and ₹5,000", "...": "..." }
]
}
{
"success": false,
"message": "Your ZapPay wallet is full. Upgrade your plan or withdraw first."
}
{
"success": false,
"message": "Payment system is under maintenance."
}
Returns the current status of an order. No request body — orderId is a path parameter (the order_id returned by create-order). Requires the X-ZapAPI-Key header.
Orders are scoped to the calling key's owner — you can never fetch another merchant's order, even by guessing its ID.
{
"success": true,
"message": "Status fetched",
"data": {
"order_id": "ZP1782...ABCD",
"status": "success",
"amount": 100
}
}
status is always exactly one of: "pending" · "success" · "failed".
{
"success": false,
"message": "Order not found"
}
Pick your stack. Each example is complete and runnable — swap in your ZapAPI key and you're live.
Drop in the widget script, set your callbacks, and call createOrder. The widget handles opening the payment popup and polling for you.
<script src="https://zappay.shop/zappay-pay.js"></script>
<button onclick="payNow()">Pay ₹100</button>
<script>
ZapPay.setCallbacks({
onSuccess: (order) => {
// order = { order_id, amount }
console.log('Payment successful:', order.order_id);
// TODO: confirm server-side via order-status before fulfilling
},
onFailed: (order) => {
// order = { order_id, amount } OR { error } on network/validation failure
console.log('Payment failed:', order);
},
onTimeout: (order) => {
// order = { order_id, amount }
// Fires after ~2 minutes of polling, or if the popup was closed
console.log('Payment timed out:', order);
}
});
function payNow() {
ZapPay.createOrder({
zap_api: 'YOUR_ZAPAPI_KEY',
amount: 100,
title: 'Order #123', // optional
customer_mobile: '9999999999' // optional
});
}
</script>
Only YOUR_ZAPAPI_KEY needs to change — the widget script URL and API base are already correct and require no configuration.
createOrder always sends useEmbedded: true and opens payment_url in a 430×700 popup (falling back to a same-tab redirect if the popup is blocked). It then polls order-status every 2.5s, up to 48 times (~2 minutes), using the X-ZapAPI-Key header — and fires your callbacks automatically.zappay-pay.js (e.g. a stale cached copy, or the code pasted in without the latest version). Re-copy the script tag above and reload with a hard refresh.onFailed fires with {"error":"Validation failed"}: this means createOrder() reached the API fine, but the API rejected the body. Almost always it's amount being passed as a formatted price (e.g. a product price shown as "₹499" pulled straight from the DOM/UI instead of the plain number 499), or customer_mobile containing spaces/dashes/a country code with a space. Sanitize both before calling createOrder — never pass a price string or raw user-typed phone number through unchanged.Two scripts: one to create the order server-side and redirect the customer, and one to handle their return and confirm payment before trusting it.
<?php
$ch = curl_init('https://zappay-beta.vercel.app/api/developer/create-order');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'zap_api' => 'YOUR_ZAPAPI_KEY',
'amount' => 100,
'title' => 'Order #123',
'redirect_url' => 'https://yourstore.com/payment-return.php'
]));
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
if ($result['success']) {
// Send the customer to complete payment
header('Location: ' . $result['data']['payment_url']);
exit;
} else {
// Handle error — see API Reference for all possible messages
echo 'Could not start payment: ' . $result['message'];
}
<?php
// $_GET['zp_order'] and $_GET['zp_result'] are appended by ZapPay automatically.
// IMPORTANT: zp_result in the query string must NEVER be trusted alone —
// it can be spoofed. Always confirm via order-status server-side.
$orderId = $_GET['zp_order'] ?? null;
if (!$orderId) {
die('Missing order id.');
}
$ch = curl_init("https://zappay-beta.vercel.app/api/developer/order-status/{$orderId}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-ZapAPI-Key: YOUR_ZAPAPI_KEY'
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
if ($result['success'] && $result['data']['status'] === 'success') {
// Verified server-side — safe to fulfill the order now
echo 'Payment confirmed for order ' . $result['data']['order_id'];
} elseif ($result['success'] && $result['data']['status'] === 'pending') {
echo 'Payment still pending — check again shortly.';
} else {
echo 'Payment failed or order not found.';
}
OkHttp example: create the order, open payment_url in a Chrome Custom Tab, then poll order-status until it resolves.
OkHttpClient client = new OkHttpClient();
String ZAP_API_KEY = "YOUR_ZAPAPI_KEY";
String BASE_URL = "https://zappay-beta.vercel.app";
// 1. Create the order
public void createOrder(double amount, String title, OrderCallback callback) {
JSONObject body = new JSONObject();
try {
body.put("zap_api", ZAP_API_KEY);
body.put("amount", amount);
body.put("title", title);
body.put("useEmbedded", true);
} catch (JSONException e) {
callback.onError(e.getMessage());
return;
}
RequestBody requestBody = RequestBody.create(
body.toString(), MediaType.parse("application/json"));
Request request = new Request.Builder()
.url(BASE_URL + "/api/developer/create-order")
.post(requestBody)
.build();
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
callback.onError(e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try {
JSONObject json = new JSONObject(response.body().string());
if (json.getBoolean("success")) {
JSONObject data = json.getJSONObject("data");
String orderId = data.getString("order_id");
String paymentUrl = data.getString("payment_url");
// 2. Open payment_url in a Chrome Custom Tab
openCustomTab(paymentUrl);
// 3. Start polling for the result
pollOrderStatus(orderId, callback);
} else {
callback.onError(json.getString("message"));
}
} catch (JSONException e) {
callback.onError(e.getMessage());
}
}
});
}
void openCustomTab(String url) {
CustomTabsIntent intent = new CustomTabsIntent.Builder().build();
intent.launchUrl(context, Uri.parse(url));
}
// Poll order-status every ~2.5s until it resolves
Handler handler = new Handler(Looper.getMainLooper());
public void pollOrderStatus(String orderId, OrderCallback callback) {
Request request = new Request.Builder()
.url(BASE_URL + "/api/developer/order-status/" + orderId)
.header("X-ZapAPI-Key", ZAP_API_KEY)
.get()
.build();
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
callback.onError(e.getMessage());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try {
JSONObject json = new JSONObject(response.body().string());
if (json.getBoolean("success")) {
String status = json.getJSONObject("data").getString("status");
if (status.equals("success")) {
callback.onSuccess(json.getJSONObject("data"));
} else if (status.equals("failed")) {
callback.onFailed(json.getJSONObject("data"));
} else {
// still "pending" — poll again in 2.5s
handler.postDelayed(() ->
pollOrderStatus(orderId, callback), 2500);
}
} else {
callback.onError(json.getString("message"));
}
} catch (JSONException e) {
callback.onError(e.getMessage());
}
}
});
}
public interface OrderCallback {
void onSuccess(JSONObject data);
void onFailed(JSONObject data);
void onError(String message);
}
onSuccess, once pollOrderStatus has confirmed status == "success" from your own server-to-server call — never on the basis of the Custom Tab simply closing.order-status from your server and check for status === "success" before fulfilling an order — this is true whether you're using the raw API, the JS widget, or the PHP redirect flow.create-order again for the same purchase while a previous order for it is still pending — check its status first, or you risk creating duplicate orders.Every ZapPay account has a Gateway Mode, switchable any time from Developer Portal → Zap API in your dashboard. It applies to your whole account — both create-order and every payment link — and defaults to Live, so nothing changes for an existing integration until you switch it yourself.
create-order and payment links both return a payment_url pointing at ZapPay's own simulator instead of a real UPI checkout. Whoever opens it sees a small "Test Action" button that reveals three outcomes: Successfully, Failed, and Cancelled. Picking one updates the order's status and sends you the same kind of notification a real payment would — clearly labelled 🧪 Test — but your wallet is never credited, since no money ever moved.The important part for your integration: nothing else changes shape. order-status returns the exact same pending / success / failed values either way, so whatever polling logic you already wrote for Live Mode — including the JS widget's built-in polling — works against Test Mode without any special-casing. That's the whole point: flip one switch, run your real integration code against a safe sandbox, flip it back when you're confident.
Paste this into ChatGPT, Claude, or any AI assistant and it will have everything it needs to integrate ZapPay correctly — in HTML/JS, PHP, or Java — on the first try.