Skip to content

Developers

API documentation

One REST API for UPI payments. Create a free account to get test keys and try every call against the sandbox.

Authentication

Authenticate every request with a secret key in the Authorization header. Keys starting sk_test_ only touch sandbox data; sk_live_ keys create real UPI payments on your linked Paytm for Business merchant account. Keep keys on your server — never in apps or browsers. Keys can be limited to IP addresses.

curl
curl https://shubhgateway.com/api/v1/payments \
  -H "Authorization: Bearer sk_test_xxxxxxxxxx_…"

Create a payment

Creates a UPI payment request and returns a dynamic QR, a UPI intent link and a hosted payment page. The response status is never SUCCESS — wait for the webhook or poll status.

POST /payments
curl -X POST https://shubhgateway.com/api/v1/payments \
  -H "Authorization: Bearer $SHUBHGATEWAY_KEY" \
  -H "Idempotency-Key: order-10001-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "client_txn_id": "ORDER-10001",
    "amount": 500,
    "currency": "INR",
    "customer": { "name": "Rahul", "email": "[email protected]", "mobile": "9876543210" },
    "description": "Order payment",
    "callback_url": "https://example.com/webhooks/shubhgateway",
    "redirect_url": "https://example.com/orders/10001"
  }'
201 Created
{
  "success": true,
  "data": {
    "payment_id": "pay_7Hq2Lm9Xc4Tz1A",
    "client_txn_id": "ORDER-10001",
    "status": "PENDING",
    "amount": 500,
    "amount_minor": 50000,
    "currency": "INR",
    "payment_url": "https://shubhgateway.com/pay/pay_7Hq2Lm9Xc4Tz1A",
    "upi_intent": "upi://pay?pa=…&am=500.00&cu=INR&tr=…",
    "qr": "data:image/png;base64,…",
    "expires_at": "2026-09-29T10:15:00.000Z"
  }
}

Payment status

Statuses: CREATED, INITIATED, PENDING, SUCCESS, FAILED, EXPIRED, CANCELLED, UNKNOWN, REVERSED, DISPUTED. Only SUCCESS (and DISPUTED after it) mean money was received — confirmed server-to-server by ShubhGateway, never by a redirect, app callback or QR scan.

GET
curl https://shubhgateway.com/api/v1/payments/pay_7Hq2Lm9Xc4Tz1A -H "Authorization: Bearer $SHUBHGATEWAY_KEY"
curl https://shubhgateway.com/api/v1/payments/status/ORDER-10001 -H "Authorization: Bearer $SHUBHGATEWAY_KEY"

Dynamic QR & UPI intent

qr is a PNG data URL of qr_string, unique to the payment and amount. On mobile, open upi_intent (or one of apps[]) to launch a UPI app. Returning to your app is not proof of payment — check the status.

Paytm accounts are scan-only: for payments collected on a linked Paytm account, upi_intent is null. Paytm for Business UPI IDs are in-store (offline) merchants, and UPI apps must block one-tap intent payments to them (“Payment failed as per UPI risk policy”). Show the QR; a customer on the same phone saves it and scans it from the gallery in any UPI app (the hosted payment page guides them). Don't launch qr_string as an intent. UPI apps cap a payment from a gallery image at ₹2,000, so above that a customer on one phone can't use this route.

POST /payment-links
curl -X POST https://shubhgateway.com/api/v1/payment-links -H "Authorization: Bearer $SHUBHGATEWAY_KEY" -H "Content-Type: application/json" \
  -d '{ "amount": 1499, "description": "Consultation", "reference": "INV-2041", "single_use": true, "expires_at": "2026-10-31T18:30:00Z" }'

Linking Paytm: with or without the merchant key

Live payments go straight into your own Paytm for Business account. Link it in Business & UPI in one of two ways; your integration with ShubhGateway is the same either way. Most merchants use MID + UPI ID.

Paytm: MID + UPI ID compared with the merchant key
MID + UPI ID (recommended)Merchant Key
What you enterMID and the account’s UPI IDMID, merchant key, Client ID and POS ID
Paytm requirementsAny Paytm for Business account with a UPI IDDynamic QR enabled by Paytm (offered to select merchants)
How a payment is confirmedPaytm is checked every 10 s; Confirm with UTR as backupPaytm’s signed webhook, plus a signed check every 10 s
Time to “Success”About 10–20 secondsA few seconds (webhook)
QR / payment linkUPI QR to your UPI ID, works in every UPI appPaytm dynamic QR; expires after 10 minutes
ReliabilityUses a Paytm status check Paytm doesn’t document: may change without noticePaytm’s official, documented API
Secrets sharedNoneMerchant key (encrypted, never shown again)

Webhooks

Configure one endpoint per mode in Developers → Webhooks. Events: payment.created, payment.pending, payment.success, payment.failed, payment.expired, payment.cancelled, payment.unknown, payment.reversed, payment.disputed. A per-payment callback_url also receives that payment’s events. Respond 2xx within the timeout; failed deliveries are retried after 1m, 5m, 15m, 30m, 1h, 3h, 6h and 24h.

payment.success
{
  "id": "evt_Qx3…",
  "type": "payment.success",
  "created_at": "2026-09-29T10:01:26.000Z",
  "mode": "live",
  "api_version": "2026-09-29",
  "data": { "payment": { "payment_id": "pay_7Hq2Lm9Xc4Tz1A", "status": "SUCCESS", "amount": 500, "utr": "412345678901", … } }
}

Verifying signatures

Every delivery carries X-ShubhGateway-Event-ID, X-ShubhGateway-Timestamp and X-ShubhGateway-Signature: v1=<hex> — HMAC-SHA256 of "{timestamp}.{raw body}" with your webhook secret. Compute it over the raw body, compare in constant time against every v1 value (two appear during secret rotation), reject timestamps older than 5 minutes, and ignore event ids you already processed.

Node.js
import crypto from 'node:crypto';

export function verify(rawBody, headers, secret) {
  const ts = headers['x-shubhgateway-timestamp'];
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  return headers['x-shubhgateway-signature'].split(',').some((part) => {
    const given = part.trim().replace(/^v1=/, '');
    return given.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  });
}
PHP
$ts = $_SERVER['HTTP_X_SHUBHGATEWAY_TIMESTAMP'];
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
$ok = abs(time() - (int)$ts) <= 300 && array_reduce(
  explode(',', $_SERVER['HTTP_X_SHUBHGATEWAY_SIGNATURE']),
  fn($c, $p) => $c || hash_equals($expected, preg_replace('/^v1=/', '', trim($p))), false);
Python
import hmac, hashlib, time

def verify(raw_body: bytes, ts: str, header: str, secret: str) -> bool:
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, p.strip().removeprefix("v1=")) for p in header.split(","))

Idempotency

Send Idempotency-Key on POST requests. The same key with the same body returns the original result (Idempotent-Replayed: true); with a different body it returns 409 IDEMPOTENCY_CONFLICT. Without a key, client_txn_id de-duplicates payments: one order can never create two payments. Safe to retry after timeouts.

Sandbox

Test keys use a simulated UPI network; no real money moves. The amount picks the outcome (configurable by your platform):

₹100Success
₹101Failed
₹102Pending (then expires)
₹103UPI timeout → recovered
₹104Success, then reversed
₹105Expired
₹106Duplicate webhooks
₹107Delayed webhook
₹108Amount mismatch

Any other amount succeeds. You can also play an outcome with POST /payments/:id/simulate.

Errors

Error codes
CodeHTTPMeaning
AUTHENTICATION_ERROR401Missing, invalid, revoked or wrong-environment key
INVALID_REQUEST400A field is missing or malformed (see error.details)
INVALID_AMOUNT400Amount must be > 0 with at most 2 decimals, within limits
INVALID_CURRENCY400Only INR is supported for UPI
MERCHANT_INACTIVE403Live payments need an active merchant account
PAYMENT_NOT_FOUND404No such payment for this key’s merchant and mode
DUPLICATE_REQUEST409client_txn_id reused with different details
IDEMPOTENCY_CONFLICT409Idempotency-Key reused with a different body
REQUEST_IN_PROGRESS409The same request is still running — retry shortly
INVALID_STATE409The action isn’t allowed in the payment’s current status
RATE_LIMITED429Too many requests — honour Retry-After
UPI_ERROR502The UPI network refused the request
UPI_UNAVAILABLE503UPI collection is unavailable right now (or live mode isn’t enabled)
UPI_TIMEOUT504The UPI network didn’t answer; the payment is UNKNOWN and is confirmed automatically
SIGNATURE_INVALID401Inbound webhook signature did not verify
INTERNAL_ERROR500Unexpected error (no internals are ever returned)
Error body
{ "success": false, "error": { "code": "INVALID_AMOUNT", "message": "…", "details": null }, "requestId": "…" }

Rate limits

Limits apply per API key, per merchant (payments and reads separately) and per IP. A 429 response includes Retry-After in seconds. The OpenAPI document is at https://shubhgateway.com/api/v1/public/gateway/openapi.json.

Changelog

  • 2026-11-02 — Payments include requested_amount. On a UPI ID confirmed by the companion app, amount is what the customer pays, a few paise above it, so the payment can be recognised. confirmed_by can now be companion_app: reported by your paired phone, not verified by the UPI network.
  • 2026-10-02 — Refunds are no longer part of ShubhGateway: /refunds, the refund statuses and refund.* / payment.*refund* webhook events, refunded_amount and the REFUND_* error codes were removed. Return money to a customer from your own UPI or merchant app.
  • 2026-09-30 — Payments and webhooks include confirmed_by: upi_network, or merchant_utr when you confirmed the payment yourself with its UTR (Paytm linked without a merchant key; not verified by the UPI network).
  • 2026-09-30 — Responses no longer include provider, provider_order_id, provider_transaction_id or provider_refund_id; use utr for the UPI reference. Error codes PROVIDER_* are now UPI_ERROR, UPI_TIMEOUT and UPI_UNAVAILABLE.
  • 2026-09-29 — API v1: payments with dynamic QR and UPI intent, payment links, refunds, webhooks with HMAC signatures, sandbox outcomes.