Tourna Pay API (v1)
Base URL: https://pay.tourna.xyz/api/v1
Money goes straight to the merchant's own UPI account (Paytm for Business). Tourna Pay creates the order, shows the QR, verifies the payment with the provider, and tells your server by webhook.
Authentication
Create a key in Dashboard → API & Webhooks. It is shown once. Only its SHA-256 hash is stored.
Authorization: Bearer pgw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Call the API from your server only. Never ship the key inside an app or a web page.
Create an order
POST /api/v1/orders (JSON body)
| field | required | notes |
|---|---|---|
amount | yes | string, rupees with max 2 decimals: "49", "49.50". Min 1.00. |
client_order_id | recommended | your ID, max 64 chars [A-Za-z0-9_-.:@/]. Idempotent: sending the same one again returns the same order, never a second one. |
customer_ref | no | your user ID. Limits open (pending) orders per customer (default 3). |
note | no | max 200 chars, shown on the payment page. |
callback_url | no | https webhook URL for this order only (overrides the account webhook). |
redirect_url | no | https URL the payer is sent to after success/failure. We append ?order_id=…&status=…&client_order_id=…. Do not trust these query params. Confirm with the webhook or GET /orders/{id}. |
curl -X POST https://pay.tourna.xyz/api/v1/orders \
-H "Authorization: Bearer $PAYGW_KEY" \
-H "Content-Type: application/json" \
-d '{"amount":"49.00","client_order_id":"match-881-user-42","customer_ref":"user-42","note":"Sunday cup entry"}'201 Created (or 200 with "idempotent_replay": true for a repeated client_order_id):
{
"order": {
"order_id": "PGK7M2XQ9R4TZP8WN3HVCA",
"client_order_id": "match-881-user-42",
"status": "pending",
"amount": "49.00",
"currency": "INR",
"payment_url": "https://pay.tourna.xyz/p/PGK7M2XQ9R4TZP8WN3HVCA",
"upi_link": "upi://pay?pa=…&am=49.00&cu=INR&tr=PGK7M2XQ9R4TZP8WN3HVCA&tn=…",
"qr_url": "https://pay.tourna.xyz/p/PGK7M2XQ9R4TZP8WN3HVCA/qr.png",
"expires_at": "2026-09-25T12:35:00+00:00",
"created_at": "2026-09-25T12:30:00+00:00",
"paid_at": null, "utr": null, "txn_id": null, "payment_mode": null,
"customer_ref": "user-42", "note": "Sunday cup entry"
},
"idempotent_replay": false
}Show the payer payment_url (web/WebView), or open upi_link directly on Android.
Get an order
GET /api/v1/orders/{order_id}, or GET /api/v1/orders?client_order_id=…
Statuses:
| status | meaning | final? |
|---|---|---|
pending | QR is live and we are watching it | no |
paid | exact amount received within the QR timer, verified with the provider | yes |
paid_late | exact amount received, but the provider timestamp is after the QR expired. Decide yourself whether to honour it | yes |
expired | timer ended and the provider confirmed nothing was paid. We keep re-checking for ~15 min (a payment made inside the timer but confirmed late still becomes paid) | mostly |
needs_review | money arrived with a different amount, the UTR is already linked to another order, or the provider could not be reached. An admin approves (→ paid) or rejects (→ rejected) | no |
rejected | admin rejected the review | yes |
There is no failed status. A failed attempt or a provider timeout never closes an order: we keep checking until the timer ends.
Webhook
When an order is paid we POST JSON to your webhook URL.
| event | sent | when |
|---|---|---|
payment.success | always | order became paid (also after an admin approves a review) |
payment.late | always | order became paid_late |
payment.expired, payment.review, payment.rejected | only if "also send expired / review events" is ticked | the matching status change |
Credit your user only on payment.success. Handle payment.late deliberately, for example by manual support.
POST /your/webhook Content-Type: application/json X-Paygw-Event: payment.success X-Signature: 5d41402abc4b2a76b9719d911017c592… (hex HMAC-SHA256 of the raw body)
{"event":"payment.success","order_id":"PGK7M2XQ9R4TZP8WN3HVCA","client_order_id":"match-881-user-42",
"status":"paid","amount":"49.00","currency":"INR","utr":"526812345678","txn_id":"2026092511121280001",
"payment_mode":"UPI","customer_ref":"user-42","note":"Sunday cup entry",
"paid_at":"2026-09-25T12:31:04+00:00","sent_at":"2026-09-25T12:31:05+00:00"}- Reply
2xxwithin 10 seconds. Anything else is retried after 30 s, 2 min, 10 min, 1 h and 6 h. After 5 attempts we stop; use "Resend webhook" in the dashboard. - Always verify
X-Signaturewith your signing secret (Dashboard → API & Webhooks). - Make your handler idempotent. The same event can arrive twice (for example after a timeout). Key your credit on
order_id. - Also check that
amountequals what you expected for yourclient_order_id. - The hosted payment page shows the payer only a minimal receipt (amount, business name, time, last 8 characters of the order ID). UTR and transaction IDs appear only in your dashboard, the API and webhooks.
Verify the signature (PHP)
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!hash_equals(hash_hmac('sha256', $raw, getenv('PAYGW_WEBHOOK_SECRET')), $sig)) {
http_response_code(401); exit;
}
$e = json_decode($raw, true);
if ($e['event'] === 'payment.success') {
// credit once: UPDATE ... WHERE client_order_id = ? AND status <> 'paid'
}
http_response_code(200);Verify the signature (Node.js)
const crypto = require('crypto');
app.post('/paygw-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', process.env.PAYGW_WEBHOOK_SECRET).update(req.body).digest('hex');
const got = req.get('X-Signature') || '';
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return res.sendStatus(401);
const e = JSON.parse(req.body);
// credit e.client_order_id once
res.sendStatus(200);
});Create an order: PHP
function paygw_create_order(string $amount, string $clientOrderId, string $customerRef): array {
$ch = curl_init('https://pay.tourna.xyz/api/v1/orders');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('PAYGW_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['amount' => $amount, 'client_order_id' => $clientOrderId, 'customer_ref' => $customerRef]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$res = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 300) throw new RuntimeException($res['error']['message'] ?? "paygw error $code");
return $res['order']; // redirect the user to $order['payment_url']
}Create an order: JavaScript (Node 18+)
async function createOrder(amount, clientOrderId, customerRef) {
const r = await fetch('https://pay.tourna.xyz/api/v1/orders', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PAYGW_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, client_order_id: clientOrderId, customer_ref: customerRef }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message || `paygw ${r.status}`);
return d.order; // send d.order.payment_url to the app
}Errors
{"error":{"code":"invalid_amount","message":"amount must be a string like \"499.00\" (min 1.00, max 2 decimals)."}}| HTTP | code | meaning |
|---|---|---|
| 401 | unauthorized | missing, invalid or revoked key, or the account is suspended |
| 409 | conflict | client_order_id reused with a different amount |
| 409 | no_active_upi_account | connect or resume a UPI account in the dashboard |
| 422 | invalid_* | a field failed validation |
| 429 | rate_limited, too_many_pending_orders, too_many_pending_for_customer | slow down, or let old orders finish or expire |
Limits (admin-tunable): 120 order creates per minute per key; 5-minute QR validity.
Unique amounts (FamPay accounts)
Orders paid into a FamPay account (phone-confirmed) get a unique amount: a few paise are added to what you asked for
(e.g. "10.00" → "10.03"). Responses and webhooks carry both amount (what the payer must pay, shown on the QR) and
base_amount (what you requested). For Paytm accounts both are always equal. Idempotency (client_order_id) compares
against base_amount. If too many open orders share one base amount the API answers 429 amount_slots_full; if the
merchant's phone is offline it answers 503 payments_paused_device_offline.