Introduction
The UPI Gateway API lets you create UPI payment orders, redirect customers to a hosted checkout, and receive signed webhooks when payments complete. All API calls are server-to-server — never expose your secret in a browser or mobile app.
HMAC-SHA256 signed webhooks. Credentials encrypted at rest.
Instant payment confirmation via polling or webhooks.
Distribute load across multiple UPI accounts automatically.
Live API authentication
Every Live request must include your Live API key and secret as headers. Find them in Dashboard → API & Webhooks. Credentials select the environment; do not send an environment field.
Never expose your X-Api-Secret in client-side code, mobile apps, or public repositories.
X-Api-Key: key_live_placeholder
X-Api-Secret: sec_live_placeholder
Accept: application/json
Content-Type: application/json
Sandbox API authentication
Generate separate Sandbox credentials under Dashboard → API & Webhooks. Send the same API requests using the test_key_… and test_sec_… values. The credential determines the environment; request parameters cannot switch it.
/sandbox/pay/, and webhook payloads include "environment": "sandbox" and "test": true.
X-Api-Key: test_key_placeholder
X-Api-Secret: test_sec_placeholder
Accept: application/json
Content-Type: application/json
Testing guide
- Generate Sandbox credentials and configure a separate Sandbox webhook URL in Settings.
- Create an order with the Sandbox headers. Do not send
payment_account_id. - Open the returned TEST
payment_urland choose success, failure, pending, expired, timeout, or invalid reference. - Check the order with the same Sandbox credentials and verify the Sandbox webhook with the Sandbox secret.
Each TEST order can be simulated once. Timeout returns HTTP 504; invalid reference and repeat attempts return HTTP 422. Pending remains pending but is still consumed as that order's one simulation.
/api/v1/orders
Create payment order
Creates a pending order and returns a hosted payment_url. Live credentials use Live payment processing; Sandbox credentials return the internal TEST checkout. The server ignores client attempts to select an environment.
| Parameter | Type | Description |
|---|---|---|
amount
required
|
number | Amount in INR (₹1 – ₹1,00,000). Up to 2 decimal places. |
client_order_id
required
|
string | Your unique order ID. Allowed: A–Z, 0–9, _ and -. Max 64 chars. |
redirect_url
optional
|
HTTPS URL | Max 500 chars. Its host must be in your Settings allowlist. |
customer_name
optional
|
string | Max 100 chars. |
customer_email
optional
|
Valid email, max 255 chars. | |
customer_mobile
optional
|
string | Max 20 chars. |
note
optional
|
string | Shown in the customer's UPI app. Max 50 chars. |
payment_account_id
optional
|
integer | Live only. Pin to one of your accounts; prohibited in Sandbox. |
curl -X POST https://gateway.example.com/api/v1/orders \
-H "X-Api-Key: key_live_placeholder" \
-H "X-Api-Secret: sec_live_placeholder" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"amount": 199, "client_order_id": "INV1001", "redirect_url": "https://yoursite.com/thanks"}'
{
"success": true,
"data": {
"order_id": "ORDAB12CD34EF56GH78",
"client_order_id": "INV1001",
"amount": "199.00",
"currency": "INR",
"status": "pending",
"environment": "live",
"test": false,
"utr": null,
"submitted_utr": null,
"payment_url": "https://gateway.example.com/pay/ORDAB12CD34EF56GH78",
"upi_intent": "upi://pay?pa=merchant@upi&am=199.00&tr=ORDAB12CD34EF56GH78",
"customer": {"name": null, "email": null, "mobile": null},
"paid_at": null,
"expires_at": "2026-11-07T12:15:00+05:30",
"created_at": "2026-11-07T12:00:00+05:30"
}
}
/api/v1/orders/{order_id | client_order_id}
Check order status
Accepts either order_id or client_order_id. Live credentials may check a pending provider payment; Sandbox credentials only read simulated state and never contact a provider. Orders are visible only with credentials for the same merchant and environment. Fulfil only after this endpoint or a verified webhook reports paid.
curl https://gateway.example.com/api/v1/orders/INV1001 \
-H "X-Api-Key: key_live_placeholder" \
-H "X-Api-Secret: sec_live_placeholder" \
-H "Accept: application/json"
{
"success": true,
"data": {
"order_id": "ORDAB12CD34EF56GH78",
"client_order_id": "INV1001",
"amount": "199.00",
"currency": "INR",
"status": "paid",
"environment": "live",
"test": false,
"utr": "427123456789",
"submitted_utr": null,
"payment_url": "https://gateway.example.com/pay/ORDAB12CD34EF56GH78",
"upi_intent": "upi://pay?pa=merchant@upi&am=199.00&tr=ORDAB12CD34EF56GH78",
"customer": {"name": null, "email": null, "mobile": null},
"paid_at": "2026-11-07T12:10:00+05:30",
"expires_at": "2026-11-07T12:15:00+05:30",
"created_at": "2026-11-07T12:00:00+05:30"
}
}
API response fields
An order moves through these statuses:
Waiting for the customer to pay until expires_at.
Payment confirmed. The utr field contains the bank reference.
A customer-submitted UTR is awaiting merchant review (Live only).
The TEST payment was simulated as failed (Sandbox only).
Order was not paid before expires_at.
/api/v1/accounts
List UPI accounts
Returns all of your Live UPI accounts and their capacity. Sandbox credentials return environment: sandbox, a null rotation mode, and an empty data array so Live account details never enter the TEST path.
{
"success": true,
"environment": "live",
"rotation_mode": "random",
"data": [
{
"id": 3,
"label": "Shop 1",
"upi_id": "merchant@paytm",
"provider": "paytm_business",
"is_active": true,
"is_default": true,
"daily_limit": "100000.00",
"used_today": "42500.00",
"remaining_today": "57500.00"
}
]
}
Rotation & daily limits
When you omit payment_account_id, the gateway automatically picks an account based on your rotation mode:
A random account among those with remaining daily capacity.
Your default account is used until it hits its daily limit, then falls back to others.
Each account has a daily limit (₹1,00,000 by default). Usage includes paid orders from today plus open pending orders. If no account has capacity, the API returns 422 with an amount error.
Webhook events
Configure separate Live and Sandbox HTTPS endpoints in Dashboard → API & Webhooks. We send JSON with X-Signature and X-Event headers. Respond with any 2xx status; failed deliveries are retried up to 6 attempts with backoff.
order.paid |
Payment confirmed. Safe to fulfil the order. |
order.expired |
Order was not paid before it expired. |
order.under_review |
A Live UTR submission needs merchant review. |
order.failed |
Sandbox failure simulation. |
order.pending |
Sandbox pending simulation. |
order.timeout |
Sandbox provider-timeout simulation. |
Delivery is at least once and may be out of order. After signature verification, deduplicate the signed event_id (also sent as X-Event-Id) transactionally. Use event_sequence per order to prevent older events from reverting newer state, fulfill once, and confirm status, environment, amount and client_order_id. Queue insertion is not merchant receipt.
{
"event": "order.paid",
"event_id": "00000000-0000-4000-8000-000000000001",
"event_sequence": 2,
"occurred_at": "2026-11-07T12:10:00+05:30",
"data": {
"order_id": "ORDAB12CD34EF56GH78",
"client_order_id": "INV1001",
"amount": "199.00",
"currency": "INR",
"status": "paid",
"environment": "live",
"test": false,
"utr": "4271234567890",
"submitted_utr": null,
"payment_url": "https://gateway.example.com/pay/ORDAB12CD34EF56GH78",
"upi_intent": "upi://pay?pa=merchant@upi&am=199.00&tr=ORDAB12CD34EF56GH78",
"customer": {"name": null, "email": null, "mobile": null},
"paid_at": "2026-11-07T12:10:00+05:30",
"expires_at": "2026-11-07T12:15:00+05:30",
"created_at": "2026-11-07T12:00:00+05:30"
}
}
Every event uses the complete order object shown above. Sandbox payloads carry environment: sandbox and test: true and are sent only to the Sandbox URL.
Verify signature & replay protection
Every webhook request includes an X-Signature header in the format t={timestamp},v1={hmac}, signed with your dedicated Webhook Signing Secret (available in Dashboard → API & Webhooks). The signature is computed as HMAC-SHA256(timestamp + "." + raw_body, secret).
1. Five-minute timestamp tolerance: Reject any webhook where |current_time - timestamp| > 300 to prevent replay attacks.
2. Transactional event deduplication: Check that the signed event_id (also passed in X-Event-Id) has not been processed previously before fulfilling orders.
3. Secret fallback & merchant migration deadline: If you have not yet generated a dedicated webhook secret, your API secret is used as a fallback (always signed with timestamp replay defense). Legacy body-only signatures (X-Signature-Legacy) are now completely retired and are neither sent nor accepted. March 31, 2027 is the merchant migration deadline for all endpoints to verify timestamped X-Signature.
<?php
$body = file_get_contents("php://input");
$secret = getenv("PAYMENT_WEBHOOK_SECRET");
$header = $_SERVER["HTTP_X_SIGNATURE"] ?? "";
// Parse t=timestamp,v1=signature
$params = [];
foreach (explode(",", $header) as $pair) {
$p = explode("=", trim($pair), 2);
if (count($p) === 2) { $params[trim($p[0])] = trim($p[1]); }
}
$timestamp = isset($params["t"]) ? (int) $params["t"] : 0;
$signature = $params["v1"] ?? "";
// 1. Replay defense: 5-minute tolerance (300 seconds)
if (abs(time() - $timestamp) > 300) {
http_response_code(401);
exit("Webhook timestamp expired or invalid");
}
// 2. Verify signature: HMAC-SHA256 of timestamp + "." + body
$expected = hash_hmac("sha256", "{$timestamp}.{$body}", $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit("Invalid signature");
}
// 3. Deduplicate event transactionally and process
$event = json_decode($body, true);
// Check your database: if event_id already processed, return 200 OK without double-fulfilling.
if ($event["event"] === "order.paid") {
// Fulfil $event["data"]["client_order_id"] idempotently
}
http_response_code(200);
const crypto = require("crypto");
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Signature") || "";
const secret = process.env.PAYMENT_WEBHOOK_SECRET;
// Parse t=timestamp,v1=signature
const params = Object.fromEntries(header.split(",").map(p => p.split("=")));
const timestamp = parseInt(params.t, 10);
const signature = params.v1 || "";
// 1. Replay defense: 5-minute tolerance (300 seconds)
const now = Math.floor(Date.now() / 1000);
if (isNaN(timestamp) || Math.abs(now - timestamp) > 300) {
return res.status(401).send("Webhook timestamp expired or invalid");
}
// 2. Verify signature
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${req.body}`)
.digest("hex");
if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).send("Invalid signature");
}
// 3. Deduplicate event_id and process
const event = JSON.parse(req.body);
if (event.event === "order.paid") {
// Fulfil event.data.client_order_id (idempotently)
}
res.sendStatus(200);
});
Error codes
All errors return a JSON body with a message field. Validation errors (422) also include an errors object keyed by field name.
{
"message": "The amount field must be at least 1.",
"errors": {
"amount": ["The amount field must be at least 1."]
}
}
| Status | Meaning |
|---|---|
401 |
Missing or invalid API key / secret. |
403 |
Merchant account is disabled. |
404 |
Order not found. |
422 |
Validation failed — see the errors object for field-level details. |
429 |
Rate limit exceeded (120 requests / minute). |
504 |
Sandbox checkout simulated a provider timeout. |
500 |
Unexpected server error. Contact support if this persists. |
Code examples
Quick-start snippets for common server-side languages.
const response = await fetch("https://gateway.example.com/api/v1/orders", {
method: "POST",
headers: {
"X-Api-Key": process.env.PAYMENT_API_KEY,
"X-Api-Secret": process.env.PAYMENT_API_SECRET,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ amount: 199, client_order_id: "INV1001" }),
});
if (!response.ok) throw new Error(`API error ${response.status}`);
const { data } = await response.json();
console.log(data.payment_url);
<?php
$client = new GuzzleHttp\Client(["base_uri" => "https://gateway.example.com/api/v1"]);
$res = $client->post("/orders", [
"headers" => [
"X-Api-Key" => getenv("PAYMENT_API_KEY"),
"X-Api-Secret" => getenv("PAYMENT_API_SECRET"),
"Accept" => "application/json",
],
"json" => ["amount" => 199, "client_order_id" => "INV1001"],
]);
$data = json_decode($res->getBody(), true);
$paymentUrl = $data["data"]["payment_url"];