Dashboard

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)

fieldrequirednotes
amountyesstring, rupees with max 2 decimals: "49", "49.50". Min 1.00.
client_order_idrecommendedyour ID, max 64 chars [A-Za-z0-9_-.:@/]. Idempotent: sending the same one again returns the same order, never a second one.
customer_refnoyour user ID. Limits open (pending) orders per customer (default 3).
notenomax 200 chars, shown on the payment page.
callback_urlnohttps webhook URL for this order only (overrides the account webhook).
redirect_urlnohttps 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:

statusmeaningfinal?
pendingQR is live and we are watching itno
paidexact amount received within the QR timer, verified with the provideryes
paid_lateexact amount received, but the provider timestamp is after the QR expired. Decide yourself whether to honour ityes
expiredtimer 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_reviewmoney 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
rejectedadmin rejected the reviewyes

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.

eventsentwhen
payment.successalwaysorder became paid (also after an admin approves a review)
payment.latealwaysorder became paid_late
payment.expired, payment.review, payment.rejectedonly if "also send expired / review events" is tickedthe 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"}

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)."}}
HTTPcodemeaning
401unauthorizedmissing, invalid or revoked key, or the account is suspended
409conflictclient_order_id reused with a different amount
409no_active_upi_accountconnect or resume a UPI account in the dashboard
422invalid_*a field failed validation
429rate_limited, too_many_pending_orders, too_many_pending_for_customerslow 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.