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. |
The same event may be delivered more than once. Process it idempotently using order_id plus the event name, then confirm status, environment, amount, and your client_order_id.
{
"event": "order.paid",
"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
Compute the lowercase hexadecimal HMAC-SHA256 of the exact raw request body. Use the Live API secret for the Live endpoint and the Sandbox API secret for the Sandbox endpoint. Compare in constant time before parsing or processing the payload; never trust frontend success or an unsigned body.
<?php
$body = file_get_contents("php://input");
$secret = getenv("PAYMENT_WEBHOOK_SECRET");
$expected = hash_hmac("sha256", $body, $secret);
$given = $_SERVER["HTTP_X_SIGNATURE"] ?? "";
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
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 expected = crypto
.createHmac("sha256", process.env.PAYMENT_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const given = req.get("X-Signature") || "";
if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.sendStatus(401);
}
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"];