UPI payment gateway API
Accept UPI payments with a dynamic QR code and a hosted checkout page. Payments are verified automatically from the bank confirmation email, so you never touch card or bank details. Your server creates an order, sends the customer to the checkout, and gets told when it is paid.
Overview
POST /api/create-payment with the amount and your own order ID.checkoutUrl. They scan the QR or tap Pay with UPI app.status=SUCCESS in the return URL can be edited by anyone. Always confirm with the webhook or with GET /api/check-status/:orderId before delivering anything.Quick start
You need an API key. If you run this gateway yourself, it is the API_SECRET_KEY you set on the server. If you are using someone else's gateway, register here and use the key shown in your dashboard after approval.
curl -X POST https://taknapay.in/api/create-payment \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 99, "orderId": "ORDER_1001"}'
Authentication
Server-to-server calls send your key in the x-api-key header. Keep it on your server only, never in browser code or a mobile app.
x-api-key: YOUR_API_KEY
The same key signs the webhooks we send you, so you can prove a webhook really came from the gateway.
Create payment
| Field | Type | Rules |
|---|---|---|
amount | number | Rupees. Greater than 0, at most 10,00,000. |
orderId | string | Your unique ID. 1 to 100 characters: letters, digits and _ - . : |
{
"success": true,
"orderId": "ORDER_1001",
"amount": 99,
"status": "PENDING",
"qrCodeUrl": "data:image/png;base64,...",
"checkoutUrl": "https://taknapay.in/index.html?orderId=ORDER_1001&amount=99"
}
- Calling it again with the same
orderIdis safe. The original amount and status are kept, so a paid order can never be reset to pending. - An
orderIdalready used by another account returns409. Use IDs that are unique to you, such as your database order number. - The amount shown to the customer always comes from the gateway's record, never from the URL.
Checkout and redirect
Send the customer to checkoutUrl and append &redirectUrl= with your return page, URL-encoded. After the payment they land on our success page, then come back to your redirectUrl with two values added:
| Outcome | Customer sees | Your return URL gets |
|---|---|---|
| Paid | /success.html | ?orderId=...&status=SUCCESS |
| Cancelled | /cancel.html | ?orderId=...&status=CANCELLED |
- The checkout has a QR code, a Pay with UPI app button on phones, a Check payment status button and a Cancel payment link. It also checks by itself every few seconds.
- Cancelling does not change the order. If the customer pays later, the order still becomes
SUCCESS. - Registered merchants can only be redirected to their own registered website. Other return URLs are ignored.
- If the return URL already has a query string, the values are added correctly.
Check status
{ "success": true, "status": "SUCCESS", "amount": 99, "orderId": "ORDER_1001" }
If the order is still pending, this call also triggers a fresh look at the mailbox, so it is the quickest way to confirm a payment. Do not call it more than once every few seconds. Limit: 40 requests per minute per IP.
Public read-only details used by the checkout page: amount, status, QR code and UPI link.
Webhooks
When a payment is verified, the gateway sends a POST to your webhook URL. For registered merchants, set it in your dashboard (https only). On a self-hosted gateway it is SITE_WEBHOOK_URL.
POST https://yoursite.com/api/payment-webhook
Content-Type: application/json
x-timestamp: 1767225600000
x-signature: 9f2c... (hex)
{ "event": "payment.success", "orderId": "ORDER_1001", "status": "SUCCESS", "amount": 99, "paidAt": "2026-01-01T10:00:00.000Z" }
Verify the signature. It is an HMAC-SHA256 of timestamp + "." + raw body, using your API key, written as hex. Use the raw request body, not re-encoded JSON, and reject old timestamps.
const crypto = require('crypto');
app.post('/api/payment-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.headers['x-timestamp'] || '';
const sig = req.headers['x-signature'] || '';
const body = req.body.toString();
const expected = crypto.createHmac('sha256', process.env.GATEWAY_API_KEY).update(ts + '.' + body).digest('hex');
const ok = sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return res.sendStatus(401);
const e = JSON.parse(body);
if (e.status === 'SUCCESS') { /* mark the order paid. Do it only once per orderId. */ }
res.sendStatus(200);
});
- Reply with any
2xxstatus to confirm. Anything else counts as not received. - Retries: 3 quick attempts, then the gateway keeps re-sending every minute for 48 hours until you reply 2xx. Your handler must be safe to run twice for the same order.
- Use Send test in your merchant dashboard to try your endpoint. It sends
"event": "webhook.test"with"status": "TEST", which you should ignore. - Self-hosted main site: the old
x-api-keyheader is still sent as well, so existing integrations keep working.
Order statuses
| Status | Meaning |
|---|---|
PENDING | Order created, payment not seen yet. Checked for PENDING_WINDOW_HOURS (24 by default). |
SUCCESS | Payment found and the amount matched exactly. Final. |
FAILED | Marked failed by the admin. |
The amount must match exactly. Paying 150 for a 15 order is not accepted as payment of that order.
Errors and limits
| Code | When |
|---|---|
400 | Missing or invalid amount or orderId. |
401 | Missing or wrong API key. |
403 | Merchant account is not approved, or is suspended. |
404 | Order not found. |
409 | orderId already used by someone else. |
429 | Too many requests. Wait a minute and retry. |
500 | Server problem. Retry shortly. |
All errors look like { "success": false, "message": "..." }. Limits per IP: order lookup 60 per minute, status check 40 per minute, merchant sign-up 5 per hour, merchant login 10 per 15 minutes. Requests larger than 50 KB are rejected.
Merchant accounts
Anyone can ask to use the gateway on their own site. This is how it works:
- Open /merchant.html and register with your PAN number, a photo of your PAN card (KYC is required), your business name, email, website and an optional https webhook URL. You receive a private login token, shown once.
- The admin checks your KYC in the admin panel under Merchants and approves or rejects your request. An account cannot be approved without PAN details.
- Log in to the dashboard with your email and token. Once approved, your API key appears there, along with your orders, revenue and a webhook tester.
- Use the key with the API above. Orders you create are yours: only you receive their webhooks.
Payments go to the gateway owner's UPI account, as set by the admin. Settlement to merchants is arranged directly with the admin.
Plans and fees
Every approved merchant starts on the Free plan. Fees are taken from each successful order and shown per order in the dashboard.
| Free (default) | Pro | |
|---|---|---|
| Monthly fee | ₹0 | ₹499 (30 days) |
| Fee per transaction | 5% of the amount | ₹1 |
| Settlement time | 3 working days | 3 working days |
| Payout charge: bank (NEFT) | 1% | Free |
| Payout charge: bank (IMPS) | 1.5% | 0.5% |
| Payout charge: UPI | 2% | Free |
| Refund charge | 1% of the refund | 1% of the refund |
Buy Pro from the dashboard (₹499 is deducted from your available balance) or ask the admin to activate it. When Pro ends, the account returns to Free. A plan change applies to new orders only: each order keeps the fee of the plan it was created under.
Payouts
- Money from a successful order is available to withdraw 3 working days (Monday to Friday) after payment, minus the plan fee.
- In the dashboard open Request a payout. Pick Bank account (NEFT), Bank account (IMPS) or UPI. For a bank account enter the holder name, account number and IFSC code. For UPI enter your UPI ID. Minimum amount is ₹100.
- The payout charge (see the table above) is cut from the amount. You receive amount minus charge.
- The admin receives your request, sends the money and enters the UTR number, then accepts it. The request turns PAID and shows the UTR in your payout history.
- If the admin rejects a request, the full amount returns to your available balance.
Refunds
- In the dashboard use the Refund button next to a paid order, or open Refunds and enter the order ID. Full or partial refunds are possible. Add a reason, and optionally the customer UPI ID.
- The admin refunds the customer from the same payment account and enters the refund UTR. The request turns REFUNDED.
- The payment fee of the order (5% on Free, ₹1 on Pro) is never refunded. It stays cut.
- Every refund has a 1% refund charge on the refunded amount, on all plans. It is cut from your available balance.
- From your balance we take refund amount plus refund charge. A pending refund already lowers it; a rejected refund restores everything. Your available balance must cover the charge, and your total balance (including money still settling) must cover the refund.
Built-in pages
| Page | Purpose |
|---|---|
/index.html | Hosted checkout: QR, UPI app button, check status, cancel |
/success.html | Payment successful. Verified on the server, then returns the customer to you |
/cancel.html | Payment cancelled or failed, with try again and return options |
/merchant.html | Sign up and merchant dashboard |
/admin.html | Admin panel: transactions, merchants, health, CSV export |
/ping.html | Live status and keep-awake page |
/healthz and /api/ping | Light endpoints for uptime monitors |
Feature list
Self-hosting
Deploy on Render with GitHub. Set these environment variables on the service:
| Variable | What it does | |
|---|---|---|
MONGO_URI | required | MongoDB connection string |
API_SECRET_KEY | required | Key for your own main site. Also signs the merchant login sessions |
GMAIL_USER, GMAIL_APP_PASSWORD | required | Mailbox that receives the payment emails (IMAP enabled, app password) |
ADMIN_SECRET_PASS | for admin panel | Admin panel password |
BUSINESS_UPI, PAYEE_NAME | optional | UPI address and name shown in the payment app |
SITE_WEBHOOK_URL | optional | Webhook for orders from your own main site |
PENDING_WINDOW_HOURS | optional | How long an unpaid order keeps being checked (default 24) |
KEEP_ALIVE, KEEP_ALIVE_URL, PING_INTERVAL_MIN | optional | Self-ping on or off, its target, and its interval in minutes (default 5) |
Keeping a free Render service awake: the server pings itself, but a sleeping server cannot wake itself. Add the GitHub workflow in .github/workflows/keep-alive.yml (set the repository variable PING_URL to https://taknapay.in/healthz), or add the same URL to UptimeRobot or cron-job.org every 5 minutes. /ping.html shows whether it is working.
What is new
- Merchant sign-up with admin approval, per-merchant API keys, dashboards and webhooks.
- Signed webhooks with
x-signatureandx-timestamp. - New checkout in a Razorpay and Cashfree style, with success and cancel pages and a Check payment status button.
- Admin panel now has a Merchants tab with approve, reject, suspend and token reset, plus a source filter for orders.
- Ping system: self-ping,
/ping.html, GitHub Actions keep-alive. - Fixes: safer CSV export, input validation, rate limits, locked redirects, no duplicate order IDs across accounts.
