Developer docs

UPI payments in three steps.

Create an order, show the payment, confirm it on your server. The customer pays on our page — or inside your own site and app. The code is right below.

BASEhttps://mail.famupigateway.site/api

Master prompt any AI · any project

One text does the whole integration. Copy it, give it to ChatGPT, Claude, Gemini or your coding assistant together with your project files — it connects your existing pay button to Fast Pay. Nothing to choose.

  1. Copythe master prompt below.
  2. Pasteit into your AI with your project’s files.
  3. Fill inyour API key and webhook secret in the one config file it makes.
MASTER-PROMPT.txt≈ 4,200 words
MASTER PROMPT — connect my existing project to the Fast Pay UPI payment gateway
(Give this whole text to ChatGPT, Claude, Gemini or a coding assistant TOGETHER WITH the files of the project.)

=== YOUR ROLE ===
You are a senior payment-integration engineer and a strict security reviewer. You make the smallest change that works, you check every step against this text, and you never guess.

=== WHAT I ATTACHED ===
The source code of an EXISTING, working project: a website, a panel, a shop, a wallet, or the backend of an app. Read ALL of it before you write anything.

=== STEP 0 — WRITE THESE THREE LINES FIRST, THEN CONTINUE ===
A. The language, framework and database you found.
B. Where the existing "pay / deposit / add money / checkout" button and its amount field are (file and line), and which table holds balances or orders.
C. Which WAY from the list below you will use, and why.
If nothing is attached, or the code that talks to the database is missing, STOP and ask me for those files. Do not invent a project.

=== RULE 1 — THIS IS AN INTEGRATION, NOT A REBUILD ===
Do not redesign, modernise, clean up, rename or rewrite anything. Do not remove or change a page, feature, route, table, style or script that this payment does not need. The existing button and amount field keep their look. Change as few files and lines as possible.
Before you edit: list the existing files and features. After you edit: confirm each of them is still there and still works.
If you cannot integrate without touching unrelated code, STOP and tell me what blocks you.

=== THE GOAL ===
The user enters an amount and presses the EXISTING button → a payment box opens ON TOP OF THE SAME PAGE (QR code, one button per UPI app — Google Pay, PhonePe, Paytm, BHIM and more —, countdown) → the user pays by UPI → the page updates by itself → the user is credited (or the order is delivered) EXACTLY ONCE — also when the browser is closed right after paying, and also when the same report arrives twice.

=== THE WAY TO SHOW THE PAYMENT — decide by these rules ===
WAY A · POPUP — the default for every website. My server creates the order, the page opens data.payment_url with AntoPay.open(). No redirect.
WAY B · OWN SCREEN — for mobile apps (Android, Flutter, React Native, iOS) and only when I ask for my own design. Create the order with "checkout": "direct" and draw the QR code from data.upi_url yourself. Under the QR put one button per app from data.upi_apps (Google Pay, PhonePe, Paytm, BHIM …): a tap opens that app with the amount already filled in.
WAY C · REDIRECT — only when the page cannot run JavaScript. Send the browser to data.payment_url.
In every way callback_url is set and handled: it is where the customer lands when the popup cannot be used.

=== GATEWAY FACTS — exact and complete; nothing else exists ===
Base URL          https://mail.famupigateway.site/api
Popup script      https://mail.famupigateway.site/assets/js/antopay.js
Auth              header  X-API-Key: <secret API key>     (server side only — never in a page, an app or a log)
Bodies            JSON, header  Content-Type: application/json

  GET  /api/me                                   key      is the key right, is the account ready?   (first call of the integration)
  POST /api/create-order                         key      start a payment
  POST /api/check-status                         key      the truth about one order — the ONLY thing that may lead to a credit
  GET  /api/payment-status?token=TOKEN&wait=1    no key   live status for a SCREEN (browser or app); never a reason to credit

--- GET /api/me → 200
{ "status": "success", "data": { "ready": true, "problems": [], "notes": [], "merchant": "My Shop", "plan": "Pro", "in_site_checkout": true,
  "accounts_connected": 1, "providers": ["paytm"], "webhooks": 1, "currency": "INR", "max_amount": 25000, "default_expiry_minutes": 5 } }
"ready": false → show data.problems to me; do not go live.

--- POST /api/create-order
{ "amount": 149.00, "order_id": "MY-UNIQUE-ORDER-ID", "customer_name": "User Name", "customer_mobile": "9876543210",
  "callback_url": "https://MY-REAL-DOMAIN/pay-return", "description": "Wallet deposit", "expiry_minutes": 5 }
→ 201
{ "status": "success", "message": "Order created successfully",
  "data": { "payment_url": "https://mail.famupigateway.site/pay/8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv", "order_id": "MY-UNIQUE-ORDER-ID", "token": "8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
            "amount": 149, "currency": "INR", "customer_name": "User Name", "expires_at": "2026-01-15 10:35:41", "created_at": "2026-01-15 10:30:41" } }
With "checkout": "direct" in the body (WAY B) the answer ALSO has:
  "upi_url": "upi://pay?pa=...&am=149.00&tr=...", "amount_to_pay": 149.00, "payee_name": "My Shop", "upi_id": "name@bank" or null,
  "seconds_left": 300, "pay_before": "2026-01-15 10:35:41", "status_url": "https://mail.famupigateway.site/api/payment-status?token=...",
  "upi_apps": [ { "id": "gpay", "name": "Google Pay", "android": "intent://pay?pa=...#Intent;scheme=upi;package=com.google.android.apps.nbu.paisa.user;end", "ios": "gpay://upi/pay?pa=..." },
                { "id": "phonepe", ... }, { "id": "paytm", ... }, { "id": "bhim", "name": "BHIM", "android": "intent://...", "ios": null }, ... ]
  upi_apps = ready-made links, one per app, each carrying exactly what upi_url carries. Use them as given, never build such a link yourself.
  "android" is for a web page opened in Chrome on Android (a plain <a href>). "ios" is for an iPhone; null = that app has no link on an iPhone, hide its button there.
  The plain upi_url stays as the button "Any UPI app" (Android shows the phone's own list of UPI apps).

FIELD RULES (the mistakes that cause errors)
- amount: a NUMBER in rupees with at most two decimals (149 or 149.5). Not paise. Not a string with commas. Greater than 0, not above data.max_amount of /api/me.
- order_id: required, my own id, at most 100 characters, UNIQUE for ever — a second order with the same id answers 409. Never reuse one, not even after a failure.
- customer_name: required. Send the REAL name of the signed-in user (his name or username): the gateway shows it to him on the payment page ("who is paying") and on the receipt, and sends it back unchanged. Send "Customer" only when the project knows no name at all — then no name is shown.
- customer_mobile: optional, digits only. The payment page shows it with the middle hidden (98•••••210).
- callback_url: a full https address on the REAL public domain of the project. The gateway adds ?order_id=...&token=... itself. Never localhost.
- expiry_minutes: how long the order can be paid, counted from create-order (default 5). Give the user enough time (5 to 10); more than 15 has no use — an opened payment closes after 15 minutes.
- Answers are success ONLY with HTTP 200 / 201 AND "status": "success". Everything else is an error — read "error" and "details".
- Time-outs: 10 s to connect, 30 s in total. If the network failed (no HTTP answer), do NOT assume failure: call check-status for that order_id.
- Dates are the gateway's local time as text ("Y-m-d H:i:s"). Store them as text; do not convert time zones.

--- POST /api/check-status      { "order_id": "MY-UNIQUE-ORDER-ID" }      (GET with ?order_id= works too)
→ 200
{ "status": "success", "data": { "order_id": "MY-UNIQUE-ORDER-ID", "amount": 149, "original_amount": 149, "currency": "INR",
  "payment_status": "success", "utr": "430112345678", "paid_at": "2026-01-15 10:31:07", "payment_method": "UPI", "provider": "paytm",
  "customer_name": "User Name", "payer_name": "NAME ON THE BANK RECEIPT" } }
payment_status is "pending", "success" or "failed". An unpaid order stays "pending" for up to 15 minutes after it was created, then it is "failed" for good.
customer_name = the name I sent with the order; it never changes. payer_name = the name on the bank receipt of the person who paid (null until paid, and when the bank gives none). Show them if useful; never decide a credit by a name.
"utr" is "N/A" until the order is paid. More fields may be present — ignore what you do not need. Asking every few seconds is fine: each call makes the gateway look for the payment again.
original_amount = what I asked for. amount = what the customer really sent; it can differ by a few paise (the gateway may add them so that two payments are never confused). Compare original_amount with my order and credit MY order's amount.

--- GET /api/payment-status?token=TOKEN&wait=1      (no key; for WAY B and for my own "waiting…" screen)
→ 200  { "status": "success", "data": { "payment_status": "pending", "order_id": "...", "amount": 149, "utr": null, "paid_at": null, "seconds_left": 212, "redirect_url": null, "next_poll_ms": 1000 } }
With wait=1 the request stays open up to about 15 seconds and answers the moment the payment is known. While "pending": wait next_poll_ms, ask again. Stop at "success" or "failed" — then call MY server, which calls check-status.
This address answers 403 (code "plan_embed") when in-site checkout is not in my plan — then MY server asks check-status every 3 seconds instead.

--- ERRORS      { "error": "...", "details": "...", "code": "..." (sometimes) }
400 the body is not valid JSON                → a bug in my code
405 wrong HTTP method                         → a bug in my code
401 key missing or wrong                      → tell me to check the key in the config file
403 code "plan_embed"                         → in-site checkout is not in my plan: create the order again WITHOUT "checkout" and use WAY C (popup falls back by itself)
404 order not found (check-status)            → treat as "unknown order", never credit
409 "Duplicate order_id"                      → that id exists already: call check-status for it; create a NEW id for a new attempt
409 code "cannot_start" (only with "direct")  → no payment account can take it right now; nothing was kept, the same order_id may be sent again
422 a field is wrong                          → show "details" to me (developer), a short message to the user
422 code "plan_amount"                        → the amount is above what my plan allows per payment: show "details"
429 code "plan_orders" | "plan_daily" | "plan_volume" | "plan_links" → a limit of my plan is reached: show "details"; do not retry in a loop
503 maintenance or busy                       → try again in a few seconds, at most 2 times
5xx or no answer                              → do not assume anything; check-status later

--- POPUP (WAY A)
<script src="https://mail.famupigateway.site/assets/js/antopay.js"></script>
AntoPay.open({ url: payment_url, onSuccess: function (p) {...}, onFailed: function (p) {...}, onClose: function (p) {...} });
p.order_id, p.amount, p.utr, p.paid_at · onClose gets p.paid (true / false) · AntoPay.close() closes the box.
After a payment the popup sends the browser to callback_url after 2.6 seconds — unless onSuccess returns false (then the page stays).
If the box cannot be shown (plan without in-site checkout, a rule against frames) the script goes to the hosted page by itself; the customer then returns to callback_url.
If the project sends a Content-Security-Policy header, add https://mail.famupigateway.site to script-src and to frame-src.
What the popup reports is for the SCREEN only. It is never proof of payment.

--- WEBHOOK (the gateway calls MY server)
POST to my webhook address, JSON body, sent ONCE (no retry), my server has 8 seconds to answer with HTTP 2xx.
Headers: X-Webhook-Event (payment.success | payment.failed), X-Webhook-Signature, X-Webhook-Timestamp
X-Webhook-Signature = lowercase hex HMAC-SHA256 of the RAW request body with my webhook secret as the key.
Body: { "event": "payment.success", "status": "success", "order_id": "MY-UNIQUE-ORDER-ID", "amount": "149.00", "original_amount": "149.00", "currency": "INR", "utr": "430112345678", "paid_at": "2026-01-15 10:31:07", "customer_name": "User Name", "payer_name": "NAME ON THE BANK RECEIPT", "timestamp": 1768451467, … more fields }
The only field you need from it is order_id.
Because it is sent once, the webhook alone is NOT enough: the return page, the page script and a safety net (below) must each be able to finish the order alone.

=== REFERENCE IMPLEMENTATION — tested end to end against this gateway ===
This PHP code was run against the real API: popup, redirect, webhook, the same webhook twice, a forged webhook, another user's order, a closed browser. It passed.
If the project is PHP: use it as it is and only adapt the table and column names and the way the project gets its database connection and its signed-in user.
If the project is in another language: port it LINE BY LINE — same functions, same order of checks, same SQL conditions. Do not "improve" the logic.

=== FILE: gateway-config.php ===
<?php
// The ONLY place for the gateway values. Never print them, never send them to the browser.
return [
    'base'           => 'https://mail.famupigateway.site/api',
    'key'            => 'PASTE_YOUR_API_KEY_HERE',
    'webhook_secret' => 'PASTE_YOUR_WEBHOOK_SECRET_HERE',
    'expiry_minutes' => 5,
];

=== FILE: gateway.php ===
<?php
// Reference implementation — tested end to end against this gateway.
// Tables assumed here:  users(id, balance)  and  deposits(id, user_id, order_id UNIQUE, amount, status, gateway_token, utr, created_at, paid_at)
// status: pending | paid | failed

/** One request to the gateway. Returns [httpCode, decodedBody|null]. (0 = the gateway could not be reached) */
function gateway_call($method, $path, ?array $body = null)
{
    $cfg = require __DIR__ . '/gateway-config.php';
    $ch = curl_init(rtrim($cfg['base'], '/') . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => ['X-API-Key: ' . $cfg['key'], 'Content-Type: application/json', 'Accept: application/json'],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw = curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $data = is_string($raw) ? json_decode($raw, true) : null;
    return [$code, is_array($data) ? $data : null];
}

/**
 * START: the local order is written first (PENDING), then the gateway order is created with the same order id.
 * Returns ['ok' => true, 'order_id' => …, 'payment_url' => …]  or  ['ok' => false, 'message' => …].
 */
function gateway_start(PDO $db, $userId, $userName, $amount, $callbackUrl)
{
    $cfg = require __DIR__ . '/gateway-config.php';
    $amount = round((float)$amount, 2);
    if ($amount < 1) {
        return ['ok' => false, 'message' => 'Enter an amount of at least 1.'];
    }
    $orderId = 'DEP' . (int)$userId . 'T' . date('ymdHis') . bin2hex(random_bytes(3));      // unique, never reused
    $db->prepare("INSERT INTO deposits (user_id, order_id, amount, status, created_at) VALUES (?, ?, ?, 'pending', ?)")
       ->execute([(int)$userId, $orderId, $amount, date('Y-m-d H:i:s')]);

    [$code, $res] = gateway_call('POST', '/create-order', [
        'amount'         => $amount,
        'order_id'       => $orderId,
        'customer_name'  => trim((string)$userName) !== '' ? (string)$userName : 'Customer',
        'callback_url'   => $callbackUrl,          // the gateway adds ?order_id=…&token=… itself
        'description'    => 'Wallet deposit',
        'expiry_minutes' => (int)$cfg['expiry_minutes'],
    ]);
    if ($code !== 201 || empty($res['data']['payment_url'])) {
        $db->prepare("UPDATE deposits SET status = 'failed' WHERE order_id = ? AND status = 'pending'")->execute([$orderId]);
        $why = is_array($res) ? ($res['details'] ?? ($res['error'] ?? '')) : '';
        return ['ok' => false, 'message' => $why !== '' ? $why : 'The payment could not be started. Please try again.'];
    }
    $db->prepare("UPDATE deposits SET gateway_token = ? WHERE order_id = ?")->execute([(string)$res['data']['token'], $orderId]);
    return ['ok' => true, 'order_id' => $orderId, 'payment_url' => (string)$res['data']['payment_url']];
}

/**
 * CONFIRM: ask the gateway about one order and credit it EXACTLY ONCE.
 * Call it from everywhere — the return page, the page script, the webhook, a cron job — in any order, any number of times.
 * $onlyUserId: pass the signed-in user so nobody can confirm somebody else's order (null for webhook / cron).
 * Returns 'paid' | 'pending' | 'failed' | 'unknown'.
 */
function gateway_confirm(PDO $db, $orderId, $onlyUserId = null)
{
    $find = $db->prepare("SELECT * FROM deposits WHERE order_id = ?");
    $find->execute([(string)$orderId]);
    $dep = $find->fetch(PDO::FETCH_ASSOC);
    if (!$dep || ($onlyUserId !== null && (int)$dep['user_id'] !== (int)$onlyUserId)) {
        return 'unknown';
    }
    if ($dep['status'] === 'paid') {
        return 'paid';                                   // already credited: nothing to do
    }
    [$code, $res] = gateway_call('POST', '/check-status', ['order_id' => $dep['order_id']]);
    if ($code !== 200 || !isset($res['data']['payment_status'])) {
        return $dep['status'] === 'failed' ? 'failed' : 'pending';      // gateway not reachable now: ask again later
    }
    $data = $res['data'];
    if ($data['payment_status'] === 'success') {
        if (abs((float)$data['original_amount'] - (float)$dep['amount']) > 0.001) {
            return 'failed';                             // not the amount this order was created with
        }
        $db->beginTransaction();
        try {
            // "AND status <> 'paid'" + the row count: only ONE caller ever gets through, whoever comes first
            $mark = $db->prepare("UPDATE deposits SET status = 'paid', utr = ?, paid_at = ? WHERE order_id = ? AND status <> 'paid'");
            $mark->execute([(string)($data['utr'] ?? ''), date('Y-m-d H:i:s'), $dep['order_id']]);
            if ($mark->rowCount() === 1) {
                $db->prepare("UPDATE users SET balance = balance + ? WHERE id = ?")->execute([$dep['amount'], (int)$dep['user_id']]);
            }
            $db->commit();
        } catch (Throwable $e) {
            $db->rollBack();
            throw $e;
        }
        return 'paid';
    }
    if ($data['payment_status'] === 'failed') {
        $db->prepare("UPDATE deposits SET status = 'failed' WHERE order_id = ? AND status = 'pending'")->execute([$dep['order_id']]);
        return 'failed';
    }
    return 'pending';
}

/** WEBHOOK: the signature is checked on the RAW body first; the payment itself is still confirmed with check-status. */
function gateway_webhook(PDO $db)
{
    $cfg = require __DIR__ . '/gateway-config.php';
    $raw = (string)file_get_contents('php://input');
    $given = (string)($_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '');
    if ($cfg['webhook_secret'] === '' || $given === '' || !hash_equals(hash_hmac('sha256', $raw, $cfg['webhook_secret']), $given)) {
        http_response_code(401);
        echo 'bad signature';
        return;
    }
    $event = json_decode($raw, true);
    if (is_array($event) && !empty($event['order_id'])) {
        gateway_confirm($db, (string)$event['order_id']);
    }
    http_response_code(200);
    echo 'ok';
}

/** SAFETY NET: the webhook is sent once. Call this when the user opens the wallet page (or from a cron job every minute). */
function gateway_reconcile(PDO $db, $userId = null)
{
    $sql = "SELECT order_id FROM deposits WHERE status = 'pending' AND created_at >= ?" . ($userId !== null ? ' AND user_id = ' . (int)$userId : '') . ' ORDER BY id DESC LIMIT 5';
    $open = $db->prepare($sql);
    $open->execute([date('Y-m-d H:i:s', time() - 1800)]);
    foreach ($open->fetchAll(PDO::FETCH_COLUMN) as $orderId) {
        gateway_confirm($db, $orderId, $userId);
    }
}

=== FILE: pay-start.php ===
<?php
// The existing "Add money" button posts the amount here. Answers JSON for the page script.
require __DIR__ . '/app.php';                 // the project's own bootstrap: $db (PDO) and the signed-in $user
require __DIR__ . '/gateway.php';
header('Content-Type: application/json');
$returnUrl = APP_URL . '/pay-return.php';     // APP_URL = the real public address of this project, from its config
echo json_encode(gateway_start($db, $user['id'], $user['name'], $_POST['amount'] ?? 0, $returnUrl));

=== FILE: pay-confirm.php ===
<?php
// Called by the page script after the box says "paid", and safe to call at any time.
require __DIR__ . '/app.php';
require __DIR__ . '/gateway.php';
header('Content-Type: application/json');
$status = gateway_confirm($db, (string)($_GET['order_id'] ?? ''), $user['id']);
$balance = $db->query('SELECT balance FROM users WHERE id = ' . (int)$user['id'])->fetchColumn();
echo json_encode(['status' => $status, 'balance' => number_format((float)$balance, 2, '.', '')]);

=== FILE: pay-return.php ===
<?php
// callback_url: the customer's browser arrives here after paying (hosted page, or when the popup could not be used).
require __DIR__ . '/app.php';
require __DIR__ . '/gateway.php';
$status = gateway_confirm($db, (string)($_GET['order_id'] ?? ''), $user['id']);
header('Location: ' . APP_URL . '/wallet.php?deposit=' . $status);      // show "paid", "pending" or "failed" there

=== FILE: gateway-webhook.php ===
<?php
// The address to add in the gateway dashboard → Webhooks. No login, no CSRF check here: the signature is the proof.
require __DIR__ . '/app-db.php';              // only the database connection ($db) — no session
require __DIR__ . '/gateway.php';
gateway_webhook($db);

=== FILE: wallet-page-snippet.html ===
<!-- the existing form stays as it is; only this script is added -->
<script src="https://mail.famupigateway.site/assets/js/antopay.js"></script>
<script>
document.getElementById('depositForm').addEventListener('submit', function (e) {
  e.preventDefault();
  var form = this, button = form.querySelector('[type=submit]');
  button.disabled = true;
  fetch('pay-start.php', { method: 'POST', body: new FormData(form), credentials: 'same-origin' })
    .then(function (r) { return r.json(); })
    .then(function (res) {
      button.disabled = false;
      if (!res.ok) { alert(res.message); return; }
      var confirmNow = function () {
        fetch('pay-confirm.php?order_id=' + encodeURIComponent(res.order_id), { credentials: 'same-origin' })
          .then(function (r) { return r.json(); })
          .then(function (c) { if (c.status === 'paid') { location.reload(); } });
      };
      AntoPay.open({
        url: res.payment_url,
        onSuccess: function () { confirmNow(); return false; },   // "paid" on the screen → MY server asks the gateway; stay on this page
        onClose: confirmNow                                        // closed early: the payment may still have come in
      });
    })
    .catch(function () { button.disabled = false; alert('Network problem. Please try again.'); });
});
</script>
=== PORTING NOTES (other languages) ===
- Raw webhook body BEFORE any JSON parsing: Laravel $request->getContent() · Express express.raw({type:'application/json'}) · Django request.body · Flask request.get_data() · FastAPI await request.body() · Spring @RequestBody String · ASP.NET EnableBuffering + StreamReader · Go io.ReadAll(r.Body) · Rails request.raw_post
- Constant-time compare: hash_equals · crypto.timingSafeEqual · hmac.compare_digest · MessageDigest.isEqual · CryptographicOperations.FixedTimeEquals · hmac.Equal · ActiveSupport::SecurityUtils.secure_compare
- The webhook route has NO login and NO CSRF check (exempt only this one route). Everything else keeps its protection.
- The credit runs in ONE database transaction. Keep the condition "status <> 'paid'" in the UPDATE and credit only when exactly one row changed (or lock the row with SELECT … FOR UPDATE first).
- Money is stored as DECIMAL(12,2) or integer paise — never as float.
- MOBILE APP: the app never holds the API key or the webhook secret. It calls two endpoints of MY backend (start, confirm) and, with WAY B, the key-less payment-status address. Open the UPI app with the upi_url (Android: Intent.ACTION_VIEW; Flutter: url_launcher externalApplication; React Native: Linking.openURL) inside try/catch — when no UPI app answers, the QR code stays on the screen. For one button per app inside a NATIVE Android app: Intent(ACTION_VIEW, Uri.parse(upi_url)).setPackage(PACKAGE), where PACKAGE is the text between "package=" and ";" of that app's "android" link; on an iPhone open the app's "ios" link. A WebView must hand links that start with "intent:" or "upi:" to the system (shouldOverrideUrlLoading → Intent.parseUri / startActivity) — a WebView alone cannot open them. Ask the status again when the app comes back to the foreground.

=== REQUIRED FLOW ===
1. Find the existing pay / deposit button and amount field. Keep the UI.
2. Server: check that the user is signed in and validate the amount with the project's own rules.
3. Server: write a local order as PENDING with a new unique order id — BEFORE calling the gateway.
4. Server: call create-order with that id. On any answer other than 201 mark the local order failed and show the gateway's "details".
5. Store data.token next to the local order.
6. Page: open data.payment_url in the popup (WAY A), or draw the own screen (WAY B), or redirect (WAY C).
7. When the screen reports success, when the popup is closed, and when the customer lands on callback_url: call MY server with the order id.
8. MY server calls check-status and runs the ONE credit function. It credits only when payment_status is "success", the order belongs to this user, and original_amount equals the local amount.
9. The webhook endpoint verifies the signature on the raw body, then runs the same function (through check-status — never from the webhook's own numbers).
10. Safety net: when the user opens the wallet / orders page (or by a cron job every minute), run the same function for his local PENDING orders of the last 30 minutes.
11. Nothing that comes from the browser or the app is trusted: not the amount, not the status, not the order id's owner.
12. Pending, failed, expired, duplicate and already-paid orders each get a clear message.

=== DATABASE ===
Reuse the existing user, wallet and transaction tables. Do not build a second wallet. Add only what is missing: one table (or columns) with the order id (UNIQUE), user id, amount, status (pending / paid / failed), gateway token, UTR, created and paid time. Give me the SQL.

=== ONE CONFIG PLACE ===
All gateway values live in exactly ONE server-side place (a config file or .env), with these placeholders — I paste the real values myself after upload:
FASTPAY_BASE_URL=https://mail.famupigateway.site/api
FASTPAY_API_KEY=PASTE_YOUR_API_KEY_HERE
FASTPAY_WEBHOOK_SECRET=PASTE_YOUR_WEBHOOK_SECRET_HERE
FASTPAY_EXPIRY_MINUTES=5
Also add ONE small test page for me (signed-in admin only, or a command-line script): it calls GET /api/me and prints the answer, so that I can see in one click whether the key and the account are right.

=== SELF-CHECK — go through every line before you answer; fix what fails ===
[ ] The API key and the webhook secret appear in the one config place only — not in HTML, JavaScript, an app, a log or an error message.
[ ] The local order exists BEFORE create-order is called, and its id is new every time.
[ ] There is exactly ONE function that credits. It is the only code that changes a balance for this gateway.
[ ] That function credits only after check-status said "success", the amount matches (original_amount) and the order belongs to the user.
[ ] Calling that function twice, or from two requests at the same moment, credits once (transaction + "status <> 'paid'" + row count, or a row lock).
[ ] Return page, page script, webhook and safety net all end in that function — each of them works alone.
[ ] The webhook checks the HMAC of the RAW body with a constant-time compare BEFORE parsing, answers 2xx fast, and never credits from its own body.
[ ] A user cannot confirm another user's order (the owner is checked on the server).
[ ] No localhost address anywhere; callback_url and the webhook address come from the project's real public address.
[ ] HTTP time-outs are set; a network failure never counts as "paid" or as "failed".
[ ] No existing page, route, table, style or feature was removed or renamed.
[ ] The test page for GET /api/me exists.

=== WHAT I WANT BACK — in this order ===
1. The three lines of STEP 0.
2. The list of files you add or change — nothing else is touched.
3. The COMPLETE final content of every added or changed file. No "…", no "rest stays the same".
4. The SQL I have to run.
5. Where exactly I paste the API key and the webhook secret.
6. The exact webhook address I add in the Fast Pay dashboard (Webhooks page), and that I copy its secret into the config.
7. How I test it: open the test page (GET /api/me), then pay 1 rupee — and what I should see after: paying; closing the page right after paying; opening the return address twice.
Download .txt Open as a link
  • Carries working code — run against this gateway before release: popup, redirect, webhook, a webhook sent twice, a forged one, a closed browser.
  • Credits exactly once, also when the page is refreshed or the same report arrives twice.
  • Makes the AI check its own work against a list before it answers, and keep the rest of your project untouched.

Do not paste your real API key into an AI chat. The prompt asks for placeholders in one config file — put the real key there yourself after you upload the code.

Start here

The whole integration is on this screen. Copy, change the values, done.

1

Create the order your server

Send the amount and your own order number. Keep the API key on the server.

Create an order
curl -X POST "https://mail.famupigateway.site/api/create-order" \
  -H "X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 499,
    "order_id": "ORD-1001",
    "customer_name": "Rahim Uddin",
    "callback_url": "https://yourshop.com/thank-you"
  }'
<?php
$ch = curl_init('https://mail.famupigateway.site/api/create-order');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount'        => 499,
        'order_id'      => 'ORD-' . time(),
        'customer_name' => 'Rahim Uddin',
        'callback_url'  => 'https://yourshop.com/thank-you',
    ]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if (($res['status'] ?? '') === 'success') {
    header('Location: ' . $res['data']['payment_url']); // send the customer to the checkout
    exit;
}
echo $res['error'] ?? 'Could not create the order';
const res = await fetch('https://mail.famupigateway.site/api/create-order', {
  method: 'POST',
  headers: { 'X-API-Key': 'anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 499,
    order_id: 'ORD-' + Date.now(),
    customer_name: 'Rahim Uddin',
    callback_url: 'https://yourshop.com/thank-you',
  }),
});
const json = await res.json();

if (json.status === 'success') {
  // send the customer to the checkout
  console.log(json.data.payment_url);
}
import time
import requests

res = requests.post(
    "https://mail.famupigateway.site/api/create-order",
    headers={"X-API-Key": "anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
    json={
        "amount": 499,
        "order_id": f"ORD-{int(time.time())}",
        "customer_name": "Rahim Uddin",
        "callback_url": "https://yourshop.com/thank-you",
    },
    timeout=15,
).json()

if res.get("status") == "success":
    print(res["data"]["payment_url"])  # send the customer to the checkout

The answer has data.payment_url and data.token. Save your order as “pending” before you go on.

2

Show the payment pick one way

In-site checkout The QR opens in a box on top of your page. The customer never leaves your site.

popup.html
<!-- once, anywhere on your page -->
<script src="https://mail.famupigateway.site/assets/js/antopay.js"></script>

<button id="pay">Pay ₹149</button>

<script>
document.getElementById('pay').onclick = async function () {
  // your server creates the order (step 1) and answers with data.payment_url
  const order = await (await fetch('/create-order', { method: 'POST' })).json();

  AntoPay.open({
    url: order.payment_url,
    onSuccess: function (p) {
      // p.order_id · p.amount · p.utr  — show "thank you";
      // deliver only after YOUR SERVER confirmed (step 3)
    },
    onClose: function (p) { /* box closed — p.paid tells if the money came in */ }
  });
};
</script>
<script src="https://mail.famupigateway.site/assets/js/antopay.js"></script>

<!-- no JavaScript of your own: the link opens as a popup -->
<a href="PAYMENT_URL" data-antopay>Pay now</a>

Every plan Send the customer to our payment page. He comes back to your callback_url after paying.

redirect
// $order = the decoded answer of create-order (step 1)
header('Location: ' . $order['data']['payment_url'], true, 303);
exit;
// Node / Express
res.redirect(303, order.data.payment_url);

// in a browser, with the payment_url your server returned
window.location.href = paymentUrl;

In-site checkout Your design, your screen — a website or a mobile app. Add "checkout": "direct" in step 1 and you get the QR link, the exact amount and a status address.

own screen
// "order" = the data object your server got in step 1 (never send the API key to the screen)

// 1. QR code — draw order.upi_url with any QR library
new QRCode(document.getElementById('qr'), order.upi_url);

// 2. phones — one button per app: a tap opens THAT app with the amount filled in
var iPhone = /iPhone|iPad|iPod/.test(navigator.userAgent);
order.upi_apps.forEach(function (app) {
  var link = iPhone ? app.ios : app.android;      // null = this app has no link on an iPhone
  if (!link) return;
  var a = document.createElement('a');
  a.href = link;
  a.textContent = app.name;
  document.getElementById('apps').appendChild(a);
});

// 2b. "Any UPI app" — Android shows the phone's own list of UPI apps
document.getElementById('upiBtn').href = order.upi_url;

// 3. the amount to show: order.amount_to_pay

// 4. wait for the payment — the request stays open and answers the moment it is paid
async function waitForPayment() {
  for (;;) {
    const s = (await (await fetch(order.status_url + '&wait=1')).json()).data;
    if (s.payment_status === 'success') return showThankYou(s);   // then confirm on your server (step 3)
    if (s.payment_status === 'failed')  return showExpired();
    await new Promise(function (ok) { setTimeout(ok, s.next_poll_ms); });
  }
}
waitForPayment();
// "order" comes from YOUR backend (it called create-order with "checkout": "direct")

// open the payer's UPI app
try {
    startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(order.upiUrl)))
} catch (e: ActivityNotFoundException) {
    showQr(order.upiUrl)                       // no UPI app on this phone: show the QR instead
}

// when the user comes back from the UPI app
override fun onResume() {
    super.onResume()
    lifecycleScope.launch {
        val s = api.liveStatus(order.statusUrl + "&wait=1")        // GET, no API key
        if (s.paymentStatus == "success") backend.confirm(order.orderId)   // your server calls check-status
    }
}
// "order" comes from YOUR backend (it called create-order with "checkout": "direct")

QrImageView(data: order.upiUrl, size: 240);                 // package: qr_flutter

await launchUrl(Uri.parse(order.upiUrl),                    // package: url_launcher
    mode: LaunchMode.externalApplication);

Future<void> waitForPayment() async {
  while (mounted) {
    final r = await http.get(Uri.parse('${order.statusUrl}&wait=1'));
    final s = jsonDecode(r.body)['data'];
    if (s['payment_status'] == 'success') return confirmOnBackend(order.orderId);
    if (s['payment_status'] == 'failed') return showExpired();
    await Future.delayed(Duration(milliseconds: s['next_poll_ms']));
  }
}
curl -X POST https://mail.famupigateway.site/api/create-order \
  -H "X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 149,
    "order_id": "ORD-1001",
    "customer_name": "Rahim",
    "checkout": "direct"
  }'
{
  "status": "success",
  "data": {
    "order_id": "ORD-1001",
    "token": "8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "payment_url": "https://mail.famupigateway.site/pay/8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "checkout": "direct",
    "upi_url": "upi://pay?pa=shop@upi&pn=Your%20Shop&am=149.00&tr=ORD-1001&tn=ORD-1001&cu=INR",
    "amount_to_pay": 149,
    "payee_name": "Your Shop",
    "upi_id": "shop@upi",
    "seconds_left": 300,
    "pay_before": "2026-01-15 10:35:00",
    "status_url": "https://mail.famupigateway.site/api/payment-status?token=8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "upi_apps": [
      { "id": "gpay", "name": "Google Pay",
        "android": "intent://pay?pa=shop@upi&pn=Your%20Shop&am=149.00&tr=ORD-1001&tn=ORD-1001&cu=INR#Intent;scheme=upi;package=com.google.android.apps.nbu.paisa.user;end",
        "ios": "gpay://upi/pay?pa=shop@upi&pn=Your%20Shop&am=149.00&tr=ORD-1001&tn=ORD-1001&cu=INR" },
      { "id": "phonepe", "name": "PhonePe", "android": "intent://pay?…package=com.phonepe.app;end", "ios": "phonepe://pay?…" },
      { "id": "paytm", "name": "Paytm", "android": "intent://pay?…package=net.one97.paytm;end", "ios": "paytmmp://pay?…" },
      { "id": "bhim", "name": "BHIM", "android": "intent://pay?…package=in.org.npci.upiapp;end", "ios": null }
    ]
  }
}
3

Confirm on your server before you deliver

We call your webhook the moment the money is in. Check the signature, mark the order paid — once.

Webhook receiver
<?php
// webhook.php — receives payment events
$secret  = 'whsec_your_webhook_secret';
$payload = file_get_contents('php://input');
$sent    = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (!hash_equals(hash_hmac('sha256', $payload, $secret), $sent)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);
if ($event['event'] === 'payment.success') {
    // Mark $event['order_id'] as paid. Keep this idempotent:
    // the same event can be delivered more than once.
}
http_response_code(200);
echo 'OK';
const express = require('express');
const crypto = require('crypto');
const app = express();

// the raw body is needed to verify the signature
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha256', 'whsec_your_webhook_secret').update(req.body).digest('hex');
  const sent = req.get('X-Webhook-Signature') || '';
  if (expected.length !== sent.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sent))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  if (event.event === 'payment.success') {
    // mark event.order_id as paid (keep it idempotent)
  }
  res.send('OK');
});

app.listen(3000);
import hashlib
import hmac
from flask import Flask, request

app = Flask(__name__)
SECRET = b"whsec_your_webhook_secret"

@app.post("/webhook")
def webhook():
    expected = hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
        return "Invalid signature", 401

    event = request.get_json()
    if event["event"] == "payment.success":
        pass  # mark event["order_id"] as paid (keep it idempotent)
    return "OK"

No webhook yet? Ask POST /api/check-status with your order_id — see Check status.

One rule. The popup, the redirect and the screen only tell the customer what happened. Your server decides: deliver only after the webhook or check-status says success.

A shorter prompt for one stack

The master prompt works for every project. If you want a shorter one, pick how the customer pays and what your project is built with.

1 · How the customer pays

2 · Your project

integration-prompt.txt
You are a senior PHP payment-integration engineer and security reviewer.

I am attaching the COMPLETE source code of an EXISTING PHP project. First read the whole project and understand its architecture: authentication, users, the existing Deposit / Add Money / Checkout flow, the database schema, transaction tables, routes or controllers, and the hosting constraints.

=== RULE 1 — DO NOT REBUILD ANYTHING ===
This is an INTEGRATION-ONLY task. Do not redesign, modernise, clean up or rewrite the project. Do not remove, rename or change any existing page, feature, route, table, style or script that is not required for this payment integration. Keep the existing UI exactly as it is. Change the minimum number of files and lines.
Before editing, list the existing files and features. After editing, confirm that each of them still exists and still works.
If you cannot integrate safely without touching unrelated code, STOP and explain what is blocking you. Do not invent a replacement.

=== TASK ===
Connect the EXISTING pay / deposit button and its amount field to the Fast Pay payment gateway. The user enters an amount, presses the existing button, a payment box opens ON TOP OF THE SAME PAGE (no redirect), pays by UPI, and the page continues. The user is credited exactly once.

=== GATEWAY API ===
Base URL:       https://mail.famupigateway.site/api
Create order:   POST https://mail.famupigateway.site/api/create-order      (server only — needs the secret API key)
Check status:   POST https://mail.famupigateway.site/api/check-status      (server only — needs the secret API key)
Live status:    GET  https://mail.famupigateway.site/api/payment-status?token=TOKEN&wait=1      (no key — may be called from a browser or an app)
Popup script:   https://mail.famupigateway.site/assets/js/antopay.js
Auth header:    X-API-Key: <secret API key>
Content-Type:   application/json

Create-order body:
{
  "amount": 100.00,
  "order_id": "UNIQUE_LOCAL_ORDER_ID",
  "customer_name": "User Name",
  "customer_mobile": "9876543210",
  "callback_url": "https://MY-REAL-DOMAIN/payment/callback",
  "description": "Wallet deposit",
  "expiry_minutes": 5
}
Success (HTTP 201) returns: data.payment_url, data.order_id, data.token, data.amount, data.expires_at
With "checkout": "direct" in the body it ALSO returns: data.upi_url, data.amount_to_pay, data.payee_name, data.upi_id (can be null), data.seconds_left, data.status_url, data.upi_apps (list of { id, name, android, ios or null } — one ready-made link per UPI app)
Errors return:  { "error": "...", "details": "..." } with HTTP 400 / 401 / 403 / 404 / 409 / 422 / 429 / 5xx
HTTP 403 with "code": "plan_embed" means in-site checkout is not part of the merchant's plan: create the order again WITHOUT "checkout" and redirect to payment_url.

Check-status body:  { "order_id": "UNIQUE_LOCAL_ORDER_ID" }
Returns: data.payment_status ("pending" | "success" | "failed"), data.amount (what the customer paid), data.original_amount (what was requested), data.utr, data.paid_at

Live status returns: data.payment_status ("pending" | "success" | "failed"), data.amount, data.utr, data.paid_at, data.seconds_left, data.next_poll_ms
With wait=1 the request stays open (up to about 15 seconds) and answers the moment the payment is known. Ask again after data.next_poll_ms milliseconds while the status is "pending".

Popup: AntoPay.open({ url: payment_url, onSuccess: fn(p), onFailed: fn(p), onClose: fn(p) })  — p.order_id, p.amount, p.utr
After a payment the popup sends the browser to callback_url?order_id=...&token=... unless onSuccess returns false.

After payment on the hosted page or in the popup the customer's browser is redirected to:  callback_url?order_id=...&token=...
That redirect, the popup's onSuccess and the live status are NOT proof of payment. They only tell the SCREEN what to show.

Webhook: Fast Pay sends a POST with a JSON body to my webhook URL.
Headers: X-Webhook-Event (e.g. payment.success), X-Webhook-Signature, X-Webhook-Timestamp
Signature = lowercase hex HMAC-SHA256 of the RAW request body, using my webhook secret as the key.
Body fields: event, status, order_id, amount, original_amount, currency, utr, paid_at, customer_name (as I sent it), payer_name (name on the bank receipt, can be null), timestamp
Answer with HTTP 2xx within 8 seconds.

=== HOW THE CUSTOMER PAYS — POPUP ON TOP OF MY OWN PAGE (in-site checkout, no redirect) ===
Load the script once on the page:  <script src="https://mail.famupigateway.site/assets/js/antopay.js"></script>
The pay button calls MY server (fetch / AJAX). My server creates the order and answers with payment_url. The page then calls AntoPay.open({ url: payment_url, ... }). The QR code appears in a box on top of my page; the customer never leaves it.
In onSuccess(p): call MY server with p.order_id; the server runs check-status and credits (step 8-10); then update the page (new balance / thank-you). Never credit from the values in p.
Keep callback_url as well: the popup sends the browser there after a payment (return false from onSuccess to stay on the page), and it is the fallback when the script cannot run.
If the merchant's plan has no in-site checkout, the script continues on the hosted payment page by itself — nothing extra to code.

=== REQUIRED FLOW ===
1. Find the existing pay / deposit button and amount field. Keep the UI.
2. On the server: authenticate the current user and validate the amount with the project's own rules.
3. Create a local PENDING order with a unique order id BEFORE calling the gateway.
4. Call create-order from the SERVER with that order id and amount. Use expiry_minutes = 5.
5. Store the returned token and payment_url next to the local order.
6. Build callback_url from the real deployed domain of this project — never hard-code localhost.
7. Do NOT redirect. Return payment_url to the page as JSON and open it with AntoPay.open({ url: payment_url, onSuccess, onClose }).
8. When the customer is back on the callback page — or the popup / my own screen reports success — read order_id, load the local order and call check-status from the SERVER.
9. Credit ONLY when payment_status is "success", the order belongs to the right user, and data.original_amount equals the local order amount.
10. Use a database transaction and a row lock (or a unique constraint) so the same order can NEVER be credited twice.
11. Add a server-side webhook endpoint. Verify X-Webhook-Signature with a constant-time comparison on the raw body, then call check-status and run the SAME idempotent credit function.
12. Callback, screen report and webhook can arrive in any order, or all, or only one of them. Each must be safe on its own.
13. Never trust amount, status, order id or user id that come from the browser or the app.
14. Handle pending, failed, expired, duplicate and already-paid orders with a clear message for the user.

=== DATABASE ===
Reuse the existing user, wallet and transaction tables. Do not create a second wallet system. Add only the columns or the single table that is really needed: gateway order id (unique), user id, amount, status, token, UTR, created and paid timestamps.

=== ONE CONFIG PLACE ===
Create ONE file, config/fastpay.php, that returns an array:
FASTPAY_BASE_URL => https://mail.famupigateway.site/api
FASTPAY_API_KEY => YOUR_API_KEY
FASTPAY_WEBHOOK_SECRET => YOUR_WEBHOOK_SECRET
FASTPAY_EXPIRY_MINUTES => 5
The real API key must never appear in frontend code, HTML, JavaScript, API responses, logs or the repository. I will paste the real values into that one place myself after upload — use clear placeholders.

=== NOTES FOR PHP ===
Use plain PHP with PDO and cURL, matching the style the project already has. No framework and no Composer package unless the project already uses one. Use prepared statements, hash_equals() for the signature check and file_get_contents('php://input') for the raw webhook body.

=== WHAT I WANT BACK ===
1. A short summary of how the existing payment / deposit flow works today.
2. The list of files you will change or add — and nothing else.
3. The complete final content of every changed or added file (no "..." placeholders).
4. Any SQL migration needed.
5. The exact file and keys where I put my API key and webhook secret.
6. The exact webhook URL I must add in the Fast Pay dashboard.
7. A test checklist: successful payment, expired payment, page refresh on the callback, the webhook arriving twice.

The same prompt as a plain-text address — give it to an assistant that can open links: https://mail.famupigateway.site/docs.php?prompt=1&stack=generic&way=popup

Your own screen In-site checkout

Add "checkout": "direct" to create-order. The payment window opens at once and the answer carries everything your screen needs.

checkout: direct
curl -X POST https://mail.famupigateway.site/api/create-order \
  -H "X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 149,
    "order_id": "ORD-1001",
    "customer_name": "Rahim",
    "checkout": "direct"
  }'
{
  "status": "success",
  "data": {
    "order_id": "ORD-1001",
    "token": "8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "payment_url": "https://mail.famupigateway.site/pay/8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "checkout": "direct",
    "upi_url": "upi://pay?pa=shop@upi&pn=Your%20Shop&am=149.00&tr=ORD-1001&tn=ORD-1001&cu=INR",
    "amount_to_pay": 149,
    "payee_name": "Your Shop",
    "upi_id": "shop@upi",
    "seconds_left": 300,
    "pay_before": "2026-01-15 10:35:00",
    "status_url": "https://mail.famupigateway.site/api/payment-status?token=8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "upi_apps": [
      { "id": "gpay", "name": "Google Pay",
        "android": "intent://pay?pa=shop@upi&pn=Your%20Shop&am=149.00&tr=ORD-1001&tn=ORD-1001&cu=INR#Intent;scheme=upi;package=com.google.android.apps.nbu.paisa.user;end",
        "ios": "gpay://upi/pay?pa=shop@upi&pn=Your%20Shop&am=149.00&tr=ORD-1001&tn=ORD-1001&cu=INR" },
      { "id": "phonepe", "name": "PhonePe", "android": "intent://pay?…package=com.phonepe.app;end", "ios": "phonepe://pay?…" },
      { "id": "paytm", "name": "Paytm", "android": "intent://pay?…package=net.one97.paytm;end", "ios": "paytmmp://pay?…" },
      { "id": "bhim", "name": "BHIM", "android": "intent://pay?…package=in.org.npci.upiapp;end", "ios": null }
    ]
  }
}
// "order" = the data object your server got in step 1 (never send the API key to the screen)

// 1. QR code — draw order.upi_url with any QR library
new QRCode(document.getElementById('qr'), order.upi_url);

// 2. phones — one button per app: a tap opens THAT app with the amount filled in
var iPhone = /iPhone|iPad|iPod/.test(navigator.userAgent);
order.upi_apps.forEach(function (app) {
  var link = iPhone ? app.ios : app.android;      // null = this app has no link on an iPhone
  if (!link) return;
  var a = document.createElement('a');
  a.href = link;
  a.textContent = app.name;
  document.getElementById('apps').appendChild(a);
});

// 2b. "Any UPI app" — Android shows the phone's own list of UPI apps
document.getElementById('upiBtn').href = order.upi_url;

// 3. the amount to show: order.amount_to_pay

// 4. wait for the payment — the request stays open and answers the moment it is paid
async function waitForPayment() {
  for (;;) {
    const s = (await (await fetch(order.status_url + '&wait=1')).json()).data;
    if (s.payment_status === 'success') return showThankYou(s);   // then confirm on your server (step 3)
    if (s.payment_status === 'failed')  return showExpired();
    await new Promise(function (ok) { setTimeout(ok, s.next_poll_ms); });
  }
}
waitForPayment();
FieldTypeWhat to do with it
upi_urlrequiredstringDraw it as a QR code and put it behind an “Any UPI app” button. Use it exactly as given — it carries the reference the payment is found by.
upi_appsrequiredarrayOne ready-made link per app — id, name, android, ios. The list holds the apps this gateway shows on its own payment page (at most five; every other app opens from upi_url). Put a button per app under the QR: on Android use android, on an iPhone ios (null = no button on an iPhone). A tap opens that app with the amount filled in. Use the links as given.
amount_to_payrequirednumberThe amount to show. It can be a few paise above your price so that two payments are never confused — the customer must pay exactly this.
payee_namerequiredstringThe name the customer will see in his UPI app.
upi_idrequiredstring | nullMay be shown for typing by hand. null = this account can only be paid through the QR / link.
seconds_leftrequiredintegerTime left to pay. Show a countdown; at 0 offer a new order.
status_urlrequiredstringAsk it for the result — see Live status. No API key needed.

Not paid in time? The order turns failed. Create a new order with a new order_id for the next try.

Mobile apps

An app can be unpacked, so the API key must never be inside it. The app talks to your backend; your backend talks to us.

  1. App → your backend: “start a payment of ₹149”. The backend calls create-order with "checkout": "direct" and passes upi_url, amount_to_pay, seconds_left and status_url to the app.
  2. App: opens the UPI app with upi_url and shows the QR as a second way. For one button per app, take the package name out of the android link of upi_apps (the text between package= and ;) and start Intent(ACTION_VIEW, Uri.parse(upi_url)).setPackage(…); on an iPhone open the ios link.
  3. WebView apps: a WebView cannot open links that start with intent: or upi: by itself. Hand them to the system in shouldOverrideUrlLoading (Intent.parseUri → startActivity), otherwise the app buttons of the payment page do nothing inside your app.
  4. App: asks status_url (no key) — also every time it comes back to the foreground.
  5. App → your backend: “confirm order ORD-1001”. The backend calls check-status and delivers.
in the app
// "order" comes from YOUR backend (it called create-order with "checkout": "direct")

// open the payer's UPI app
try {
    startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(order.upiUrl)))
} catch (e: ActivityNotFoundException) {
    showQr(order.upiUrl)                       // no UPI app on this phone: show the QR instead
}

// when the user comes back from the UPI app
override fun onResume() {
    super.onResume()
    lifecycleScope.launch {
        val s = api.liveStatus(order.statusUrl + "&wait=1")        // GET, no API key
        if (s.paymentStatus == "success") backend.confirm(order.orderId)   // your server calls check-status
    }
}
// "order" comes from YOUR backend (it called create-order with "checkout": "direct")

QrImageView(data: order.upiUrl, size: 240);                 // package: qr_flutter

await launchUrl(Uri.parse(order.upiUrl),                    // package: url_launcher
    mode: LaunchMode.externalApplication);

Future<void> waitForPayment() async {
  while (mounted) {
    final r = await http.get(Uri.parse('${order.statusUrl}&wait=1'));
    final s = jsonDecode(r.body)['data'];
    if (s['payment_status'] == 'success') return confirmOnBackend(order.orderId);
    if (s['payment_status'] == 'failed') return showExpired();
    await Future.delayed(Duration(milliseconds: s['next_poll_ms']));
  }
}

iPhone: iOS cannot always open a UPI app from a link, so keep the QR visible. Simplest of all: open payment_url in the phone’s browser or a web view — that works in every app and on every plan.

Live status In-site checkout

GET/api/payment-status?token=TOKEN&wait=1

For the screen that shows the payment. It needs no API key — the order’s token is the permission — so a browser or an app may call it. With wait=1 the request stays open and answers the moment the payment is known.

FieldTypeDescription
tokenrequiredstringdata.token of the order (already inside status_url).
waitoptionalboolean1 = hold the request (up to about 15 seconds) until the payment is known.
answer.json
{
  "status": "success",
  "data": {
    "payment_status": "success",
    "order_id": "ORD-1001",
    "amount": 149,
    "currency": "INR",
    "utr": "412345678901",
    "paid_at": "2026-01-15 10:31:42",
    "seconds_left": 0,
    "redirect_url": "https://yourshop.com/thank-you?order_id=ORD-1001&token=8fK2…",
    "next_poll_ms": 0
  }
}

While payment_status is pending, wait next_poll_ms milliseconds and ask again. Stop at success or failed.

This address tells the screen what to show. Your server still confirms with the webhook or check-status before it delivers.

Authentication

Send your API key in the X-API-Key header of every request. Authorization: Bearer <key> works as well.

headers
X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

Keep the key on your server. Never put it in HTML, JavaScript, a mobile app or a public repository. If a key leaks, regenerate it in the dashboard — the old one stops working immediately.

Get the key under API Keys, and connect a payment account under Accounts first. Request bodies can be JSON or ordinary form fields. Responses are always JSON.

Test your key

GET/api/me

The first call of every integration: it tells whether the key is right and the account can take payments — before any customer pays. Nothing is created.

Test the key
curl https://mail.famupigateway.site/api/me \
  -H "X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "status": "success",
  "data": {
    "ready": true,
    "problems": [],
    "notes": [],
    "merchant": "My Shop",
    "plan": "Pro",
    "in_site_checkout": true,
    "accounts_connected": 1,
    "providers": ["paytm"],
    "webhooks": 1,
    "currency": "INR",
    "max_amount": 25000,
    "default_expiry_minutes": 5,
    "api_base": "https://mail.famupigateway.site/api",
    "popup_script": "https://mail.famupigateway.site/assets/js/antopay.js"
  }
}
FieldMeaning
readytrue — orders can be created and paid right now. false — read problems.
problemsWhat stops payments, in plain words (no payment account connected, plan expired …).
notesThings that work but should be looked at (no webhook yet, in-site checkout not in the plan …).
in_site_checkoutWhether the popup and "checkout": "direct" are part of the plan.
max_amountThe largest single payment the plan accepts.

Create order

POST/api/create-order

Creates a payment and returns the payment page for it.

Body parameters

FieldTypeDescription
amountrequirednumberAmount in INR, greater than 0 and up to 500,000. Two decimals.
order_idrequiredstringYour own unique reference, up to 100 characters. Used to look the order up later.
customer_namerequiredstringYour customer’s name. Shown on the payment page, on the receipt and in your dashboard, and sent back to you unchanged.
callback_urloptionalstringWhere the customer’s browser goes after paying. Must start with http:// or https://.
checkoutoptionalstring"direct" = also return what your own screen needs. Leave it out for the payment page and the popup.
customer_mobileoptionalstringCustomer phone number, digits only. The payment page shows it with the middle hidden (98•••••210).
descriptionoptionalstringShort note shown on the payment page (up to 500 characters).
expiry_minutesoptionalintegerHow long the payment stays open. Default 5. Use 0 for no expiry. expiry_hours is accepted too.
is_reusableoptionalbooleanAllow the same link to be paid more than once. Default false.

Request

Create an order
curl -X POST "https://mail.famupigateway.site/api/create-order" \
  -H "X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 499,
    "order_id": "ORD-1001",
    "customer_name": "Rahim Uddin",
    "callback_url": "https://yourshop.com/thank-you"
  }'
<?php
$ch = curl_init('https://mail.famupigateway.site/api/create-order');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount'        => 499,
        'order_id'      => 'ORD-' . time(),
        'customer_name' => 'Rahim Uddin',
        'callback_url'  => 'https://yourshop.com/thank-you',
    ]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if (($res['status'] ?? '') === 'success') {
    header('Location: ' . $res['data']['payment_url']); // send the customer to the checkout
    exit;
}
echo $res['error'] ?? 'Could not create the order';
const res = await fetch('https://mail.famupigateway.site/api/create-order', {
  method: 'POST',
  headers: { 'X-API-Key': 'anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 499,
    order_id: 'ORD-' + Date.now(),
    customer_name: 'Rahim Uddin',
    callback_url: 'https://yourshop.com/thank-you',
  }),
});
const json = await res.json();

if (json.status === 'success') {
  // send the customer to the checkout
  console.log(json.data.payment_url);
}
import time
import requests

res = requests.post(
    "https://mail.famupigateway.site/api/create-order",
    headers={"X-API-Key": "anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
    json={
        "amount": 499,
        "order_id": f"ORD-{int(time.time())}",
        "customer_name": "Rahim Uddin",
        "callback_url": "https://yourshop.com/thank-you",
    },
    timeout=15,
).json()

if res.get("status") == "success":
    print(res["data"]["payment_url"])  # send the customer to the checkout

Response 201 Created

response.json
{
  "status": "success",
  "message": "Order created successfully",
  "data": {
    "payment_url": "https://mail.famupigateway.site/pay/8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "order_id": "ORD-1001",
    "token": "8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "amount": 499,
    "currency": "INR",
    "customer_name": "Rahim Uddin",
    "expires_at": "2026-01-15 10:35:41",
    "created_at": "2026-01-15 10:30:41"
  }
}

Save order_id on your side, then show the payment: popup, redirect to data.payment_url, or your own screen.

An order that is not paid within its time (at most 15 minutes after the payment was shown) is marked failed.

Check status

POST/api/check-status

Returns the current state of an order — from your server, with the API key. GET with ?order_id=… works too. If the order is still pending, this call also triggers a fresh verification, so it is safe to poll every few seconds.

FieldTypeDescription
order_idrequiredstringThe order_id you sent when creating the order.

Request

Check an order
curl "https://mail.famupigateway.site/api/check-status?order_id=ORD-1001" \
  -H "X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
<?php
$ch = curl_init('https://mail.famupigateway.site/api/check-status?order_id=' . urlencode($orderId));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['X-API-Key: anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'],
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);

if (($res['data']['payment_status'] ?? '') === 'success') {
    // paid — deliver the order. Bank reference: $res['data']['utr']
}
const res = await fetch('https://mail.famupigateway.site/api/check-status?order_id=' + encodeURIComponent(orderId), {
  headers: { 'X-API-Key': 'anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
});
const { data } = await res.json();

if (data.payment_status === 'success') {
  // paid — deliver the order. Bank reference: data.utr
}
import requests

data = requests.get(
    "https://mail.famupigateway.site/api/check-status",
    params={"order_id": order_id},
    headers={"X-API-Key": "anto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
    timeout=15,
).json()["data"]

if data["payment_status"] == "success":
    pass  # paid — deliver the order. Bank reference: data["utr"]

Response 200 OK

response.json
{
  "status": "success",
  "data": {
    "order_id": "ORD-1001",
    "amount": 499,
    "original_amount": 499,
    "currency": "INR",
    "payment_status": "success",
    "customer_name": "Rahim Uddin",
    "customer_mobile": null,
    "payer_name": "RAHIM UDDIN",
    "utr": "430112345678",
    "payment_method": "UPI",
    "provider": "fampay",
    "gateway_txn_id": "12",
    "paid_at": "2026-01-15 10:31:07",
    "payment_url": "https://mail.famupigateway.site/pay/8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
    "callback_url": "https://yourshop.com/thank-you",
    "expires_at": "2026-01-15 10:35:41",
    "created_at": "2026-01-15 10:30:41"
  }
}

Payment status values

payment_statusMeaning
pendingNot paid yet — the customer has not completed the payment.
successPaid and verified. utr holds the bank reference, paid_at the time and payer_name the name on the bank receipt (null when the bank gave none). customer_name stays the name you sent.
failedThe payment window ran out or the link expired without a payment.

amount and original_amount. When two of your customers are paying the same amount at the same moment, one of them may be asked for one or two paise more, so the payments can never be confused. amount is what the customer paid, original_amount is what you asked for — compare original_amount with your own order.

Return to your site

When a payment is confirmed on the payment page or in the popup, the customer’s browser is sent to your callback_url with two query parameters:

redirect
https://yourshop.com/thank-you?order_id=ORD-1001&token=8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv

A redirect is not proof of payment — anybody can type that address. On your callback page read order_id, call /api/check-status from your server and deliver only when payment_status is success.

Errors

Errors use ordinary HTTP status codes with a small JSON body:

error.json
{
  "error": "Duplicate order_id",
  "details": "An order with this order_id already exists"
}
200Request succeeded
201Order created
400Body is not valid JSON
401API key missing, wrong or disabled
403In-site checkout is not part of the plan (plan_embed)
404No order with that order_id / token
405Wrong HTTP method
409order_id was already used, or the payment cannot start (cannot_start)
422A field is missing or invalid
429A limit of your plan is reached — see code below
500Something failed on our side — retry
503Platform is under maintenance

A 409 without a code means the order already exists — do not create it again, call check-status instead.

Plan limits

Every plan has its own limits. When create-order would pass one of them, nothing is created and the answer carries a code your software can act on. Your current usage is on the Plan page.

HTTPcodeMeaning
429plan_ordersAll transactions of the current period are used. Counted: paid orders and orders whose payment is still open — an order that was never paid gives its place back.
429plan_dailyThe transactions of today are used. Resets at midnight.
429plan_volumeThe amount would pass the total your plan may collect in one period.
429plan_linksOnly for is_reusable: every open payment link of the plan is in use.
422plan_amountThe amount is above the largest single payment of your plan.
403plan_embedYour plan has the payment page only. Leave checkout out and send the customer to payment_url.
409cannot_startOnly for "checkout": "direct": the payment could not be opened (for example no payment account is connected). Nothing was created — the same order_id can be sent again.

Webhooks

A webhook is a POST request we send to your server when something happens to a payment, so you do not have to poll. Add your endpoint under Webhooks in the dashboard; it shows the secret used to sign every request.

Events

EventSent when
payment.successA payment was verified. This is the one every integration needs.
payment.failedThe payment window ran out without a payment.

Request headers

HeaderValue
X-Webhook-EventThe event name, for example payment.success.
X-Webhook-SignatureLower-case hex HMAC-SHA256 of the raw request body, keyed with your endpoint’s secret.
X-Webhook-TimestampUnix time when the request was sent.
X-Webhook-Testtrue — only on test deliveries sent from the dashboard.

Payload

payment.success
{
  "event": "payment.success",
  "status": "success",
  "order_id": "ORD-1001",
  "amount": "499.00",
  "original_amount": "499.00",
  "currency": "INR",
  "customer_name": "Rahim Uddin",
  "customer_mobile": null,
  "payer_name": "RAHIM UDDIN",
  "utr": "430112345678",
  "payment_method": "UPI",
  "provider": "fampay",
  "gateway_txn_id": "12",
  "paid_at": "2026-01-15 10:31:07",
  "payment_url": "https://mail.famupigateway.site/pay/8fK2mQx7Lr0aTz5Wc1Yb9Ne4Hd6Sj3Uv",
  "callback_url": "https://yourshop.com/thank-you",
  "expires_at": "2026-01-15 10:35:41",
  "created_at": "2026-01-15 10:30:41",
  "timestamp": 1768451467,
  "data": {
    "order_id": "ORD-1001",
    "amount": 499,
    "original_amount": 499,
    "currency": "INR",
    "payment_status": "success",
    "utr": "430112345678",
    "paid_at": "2026-01-15 10:31:07"
  }
}

The same values are available at the top level and inside data; use whichever is more convenient.

Responding

  • Answer with any 2xx status within 8 seconds. Do slow work (e-mails, fulfilment) after you have answered.
  • The same event can reach you more than once. Make your handler idempotent: if the order is already marked paid, answer 200 and do nothing.
  • Every delivery, with your server’s response, is listed in the dashboard — where you can also send a test event.
  • For anything involving money, confirm with /api/check-status before you credit.

Verify the signature

Compute the HMAC-SHA256 of the raw request body (before any JSON parsing) with your endpoint secret and compare it to X-Webhook-Signature using a constant-time comparison.

Webhook receiver
<?php
// webhook.php — receives payment events
$secret  = 'whsec_your_webhook_secret';
$payload = file_get_contents('php://input');
$sent    = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (!hash_equals(hash_hmac('sha256', $payload, $secret), $sent)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);
if ($event['event'] === 'payment.success') {
    // Mark $event['order_id'] as paid. Keep this idempotent:
    // the same event can be delivered more than once.
}
http_response_code(200);
echo 'OK';
const express = require('express');
const crypto = require('crypto');
const app = express();

// the raw body is needed to verify the signature
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha256', 'whsec_your_webhook_secret').update(req.body).digest('hex');
  const sent = req.get('X-Webhook-Signature') || '';
  if (expected.length !== sent.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sent))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  if (event.event === 'payment.success') {
    // mark event.order_id as paid (keep it idempotent)
  }
  res.send('OK');
});

app.listen(3000);
import hashlib
import hmac
from flask import Flask, request

app = Flask(__name__)
SECRET = b"whsec_your_webhook_secret"

@app.post("/webhook")
def webhook():
    expected = hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
        return "Invalid signature", 401

    event = request.get_json()
    if event["event"] == "payment.success":
        pass  # mark event["order_id"] as paid (keep it idempotent)
    return "OK"

Go-live checklist

  • A payment account is connected and its Test button in the dashboard reports success.
  • The API key lives only in server-side configuration — not in a page, not in an app.
  • You create your local order before calling create-order, and order_id is unique.
  • Your server confirms with the webhook or check-status; it never trusts the redirect, the popup or the screen alone.
  • Your webhook verifies X-Webhook-Signature and answers within 8 seconds.
  • Crediting is idempotent — a refreshed page or a webhook that arrives twice never pays out twice.
  • You made one real small payment end to end and saw it in Transactions.

Try the live demo