v1 · Stable

API Reference

Integrate UPI payments into your app in minutes. Create orders, redirect customers, and receive real-time webhooks.

Base URL https://pay.myjbd.in/api/v1
Getting Started

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.

Secure

HMAC-SHA256 signed webhooks. Credentials encrypted at rest.

Real-time

Instant payment confirmation via polling or webhooks.

Auto-rotation

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.

Live request headers
X-Api-Key: key_live_placeholder
X-Api-Secret: sec_live_placeholder
Accept: application/json
Content-Type: application/json
SANDBOX · TEST ONLY

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 orders use an internal TEST checkout and never contact a payment provider. Their payment URLs begin with /sandbox/pay/, and webhook payloads include "environment": "sandbox" and "test": true.
Sandbox request headers
X-Api-Key: test_key_placeholder
X-Api-Secret: test_sec_placeholder
Accept: application/json
Content-Type: application/json

Testing guide

  1. Generate Sandbox credentials and configure a separate Sandbox webhook URL in Settings.
  2. Create an order with the Sandbox headers. Do not send payment_account_id.
  3. Open the returned TEST payment_url and choose success, failure, pending, expired, timeout, or invalid reference.
  4. 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.

POST /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.

Request body
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 email 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
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"}'
Response · 201 Created
{
  "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"
  }
}
GET /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
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"
Response · 200 OK
{
  "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:

Pending

Waiting for the customer to pay until expires_at.

Paid

Payment confirmed. The utr field contains the bank reference.

Under review

A customer-submitted UTR is awaiting merchant review (Live only).

Failed

The TEST payment was simulated as failed (Sandbox only).

Expired

Order was not paid before expires_at.

GET /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.

Response · 200 OK
{
  "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:

Random rotation (default)

A random account among those with remaining daily capacity.

Default account first

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.

Events
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.

Payload · order.paid
{
  "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
<?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);
Node.js
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.

Validation error · 422
{
  "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.

Node.js (create order)
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 (Guzzle)
<?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"];