ZapPay
ZapAPI Developer Documentation
Read this before you write one line of code

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.

  1. Default to the raw REST API (Section 4), not the JS widget (Section 5). The widget wraps the same two endpoints, but it fails silently when it's misconfigured: on bad or missing input it only logs 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.
  2. Never assume the widget script loaded and initialized correctly. Timing issues (script tag order, slow network, ad blockers) can leave 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.
  3. Sanitize 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.
  4. Never treat a popup closing, a redirect landing, or a client-side callback as proof of payment. All three can be spoofed, interrupted, or retried. The only trustworthy signal is your own server (or your own polling code) calling 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.
  5. Build and test the complete flow once, for real, before calling it done. Create a live order, actually pay it (or fail it) via UPI, and confirm your code reaches a correct final state — success, failure, and timeout. Reading the docs and writing plausible-looking code is not the same as watching it work.
If you are an AI assistant: do not tell the user integration is complete until you have reasoned through (or, where possible, actually tested) points 1–5 above against the code you just wrote. If you're unsure whether the widget will behave correctly in the user's environment, default to the direct fetch()-based REST flow in Section 4 — it has no hidden failure mode.
ZapPay for Developers

Accept UPI payments with three API calls

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.

1

Overview

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:

  • Never expose it in client-side code that ships to end users (the one exception is the JS widget flow — see Section 5 — where the key is used from your own page, not a third party's).
  • Never commit it to a public repository.
  • If it leaks, regenerate it immediately from your dashboard — the old key stops working the instant you do.
ZapAPI has two parts: a simple REST API (Section 4) for creating orders and checking their status, and an optional JS widget (Section 5) that wraps the REST API in a ready-made popup checkout.
2

Getting your key

Your ZapAPI key is generated automatically the first time you open the developer portal:

  1. Log in to your ZapPay dashboard.
  2. Go to Developer Portal → Zap API.
  3. Your key is generated on first visit and shown there. Copy it from that screen.

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.

3

How it works

Every ZapPay integration — widget or raw API — follows the same three steps:

  1. Create an order. Your server (or page) calls POST /api/developer/create-order with an amount. You get back a payment_url.
  2. Customer pays. Send the customer to payment_url — as a redirect, a popup, or inside a WebView — where they complete payment via any UPI app.
  3. You confirm the order. Call GET /api/developer/order-status/:orderId and check that status is "success" before you fulfill the order.
The most important rule in this document: a customer landing back on your 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.
4

API reference

Base URL

Base URL
https://zappay-beta.vercel.app

Authentication

Every request must include your ZapAPI key, passed one of two ways — the API accepts either:

  • As a field named zap_api in the JSON request body, or
  • As a request header: X-ZapAPI-Key
SituationResponse
Key missing entirely 401Missing ZapAPI key...
Key is invalid 401Invalid ZapAPI key
Account is suspended 403This account has been suspended.
401 — missing key
{
  "success": false,
  "message": "Missing ZapAPI key. Pass it as \"zap_api\" in the request body or an \"X-ZapAPI-Key\" header."
}
403 — suspended account
{
  "success": false,
  "message": "This account has been suspended.",
  "banned": true
}

Rate limit

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:

429 — rate limited
{
  "success": false,
  "message": "Too many API requests. Please try again later."
}

Create order

POST /api/developer/create-order

Creates a new UPI payment order and returns a payment_url to send your customer to. Requires header Content-Type: application/json.

FieldRequiredTypeNotes
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 number499, 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 0no 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.
#1 real-world cause of "Validation failed": sending 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.
Two conditions can reject an order even with a valid request: your wallet headroom (if current balance + this amount exceeds your plan's wallet limit, you'll get a 400 asking you to upgrade or withdraw), and platform maintenance mode (a temporary 503).
200 — order created
{
  "success": true,
  "message": "Order created",
  "data": {
    "order_id": "ZP1782...ABCD",
    "payment_url": "https://pay.zapupi.com/...",
    "amount": 100
  }
}
400 — validation failed
{
  "success": false,
  "message": "Validation failed",
  "errors": [
    { "msg": "Amount must be between ₹1 and ₹5,000", "...": "..." }
  ]
}
400 — wallet full
{
  "success": false,
  "message": "Your ZapPay wallet is full. Upgrade your plan or withdraw first."
}
503 — maintenance mode
{
  "success": false,
  "message": "Payment system is under maintenance."
}

Get order status

GET /api/developer/order-status/:orderId

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.

200 — status fetched
{
  "success": true,
  "message": "Status fetched",
  "data": {
    "order_id": "ZP1782...ABCD",
    "status": "success",
    "amount": 100
  }
}

status is always exactly one of: "pending" · "success" · "failed".

404 — not found
{
  "success": false,
  "message": "Order not found"
}
5

Integration guide

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.

index.html
<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.

Internally, 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.
If you see "Failed to fetch" / a network error from ZapPay.js: the widget calls ZapPay's backend directly — it never depends on which domain served the script file, so this is not a hosting or CORS problem on your end. It almost always means the page loaded an old or incomplete copy of 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.
If 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.

create-order.php
<?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'];
}
payment-return.php
<?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.

ZapPayClient.java
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);
}
Only mark the purchase as fulfilled inside onSuccess, once pollOrderStatus has confirmed status == "success" from your own server-to-server call — never on the basis of the Custom Tab simply closing.
6

Best practices & security

  • Never trust a redirect or client callback alone. Always call 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.
  • Keep your ZapAPI key server-side wherever possible. The JS widget is the one exception — there, the key is used from your own page and sent to ZapPay's API directly, not to a third party.
  • One checkout at a time. Don't call 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.
  • Regenerate your key immediately if you suspect it has leaked. The old key is invalidated the instant you do, with no grace period.
  • Respect the amount limit. Orders must be between ₹1 and ₹5,000 — validate this client-side too, for a better user experience, before you ever hit the API.
  • Respect the rate limit. 60 requests per 15 minutes per key. If you're polling order-status yourself instead of using the widget, poll no more often than every 2–3 seconds.
7

Test Mode & Live Mode

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.

  • Live Mode — the behaviour documented on this whole page: real UPI checkout, real money, real wallet credit.
  • Test Modecreate-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.

Leaving Test Mode on is the single most common reason a "live" integration silently stops accepting real payments. If customers report they can't pay, check Developer Portal → Zap API first.
For AI tools

Give this to an AI

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.

Jump to

1 Overview
2 Getting your key
3 How it works
4 API reference
5 Integration guide
6 Best practices & security
7 Test Mode & Live Mode
Full documentation (all languages)