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 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.
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"
}'{
"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.
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.
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.
| MID + UPI ID (recommended) | Merchant Key | |
|---|---|---|
| What you enter | MID and the account’s UPI ID | MID, merchant key, Client ID and POS ID |
| Paytm requirements | Any Paytm for Business account with a UPI ID | Dynamic QR enabled by Paytm (offered to select merchants) |
| How a payment is confirmed | Paytm is checked every 10 s; Confirm with UTR as backup | Paytm’s signed webhook, plus a signed check every 10 s |
| Time to “Success” | About 10–20 seconds | A few seconds (webhook) |
| QR / payment link | UPI QR to your UPI ID, works in every UPI app | Paytm dynamic QR; expires after 10 minutes |
| Reliability | Uses a Paytm status check Paytm doesn’t document: may change without notice | Paytm’s official, documented API |
| Secrets shared | None | Merchant 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.
{
"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.
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));
});
}$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);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):
Any other amount succeeds. You can also play an outcome with POST /payments/:id/simulate.
Errors
| Code | HTTP | Meaning |
|---|---|---|
| AUTHENTICATION_ERROR | 401 | Missing, invalid, revoked or wrong-environment key |
| INVALID_REQUEST | 400 | A field is missing or malformed (see error.details) |
| INVALID_AMOUNT | 400 | Amount must be > 0 with at most 2 decimals, within limits |
| INVALID_CURRENCY | 400 | Only INR is supported for UPI |
| MERCHANT_INACTIVE | 403 | Live payments need an active merchant account |
| PAYMENT_NOT_FOUND | 404 | No such payment for this key’s merchant and mode |
| DUPLICATE_REQUEST | 409 | client_txn_id reused with different details |
| IDEMPOTENCY_CONFLICT | 409 | Idempotency-Key reused with a different body |
| REQUEST_IN_PROGRESS | 409 | The same request is still running — retry shortly |
| INVALID_STATE | 409 | The action isn’t allowed in the payment’s current status |
| RATE_LIMITED | 429 | Too many requests — honour Retry-After |
| UPI_ERROR | 502 | The UPI network refused the request |
| UPI_UNAVAILABLE | 503 | UPI collection is unavailable right now (or live mode isn’t enabled) |
| UPI_TIMEOUT | 504 | The UPI network didn’t answer; the payment is UNKNOWN and is confirmed automatically |
| SIGNATURE_INVALID | 401 | Inbound webhook signature did not verify |
| INTERNAL_ERROR | 500 | Unexpected error (no internals are ever returned) |
{ "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,amountis what the customer pays, a few paise above it, so the payment can be recognised.confirmed_bycan now becompanion_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 andrefund.*/payment.*refund*webhook events,refunded_amountand theREFUND_*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, ormerchant_utrwhen 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_idorprovider_refund_id; useutrfor the UPI reference. Error codesPROVIDER_*are nowUPI_ERROR,UPI_TIMEOUTandUPI_UNAVAILABLE. - 2026-09-29 — API v1: payments with dynamic QR and UPI intent, payment links, refunds, webhooks with HMAC signatures, sandbox outcomes.