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://www.famupigateway.site/api Popup script https://www.famupigateway.site/assets/js/antopay.js Auth header X-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://www.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://www.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 ). "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) 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://www.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 === 'https://www.famupigateway.site/api', 'key' => 'PASTE_YOUR_API_KEY_HERE', 'webhook_secret' => 'PASTE_YOUR_WEBHOOK_SECRET_HERE', 'expiry_minutes' => 5, ]; === FILE: gateway.php === $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 === 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 === === 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://www.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.