Common issues and step-by-step fixes. Click any issue to expand.
API returns 400 with INVALID_SIGNATURE error code.
merchantId|merchantTxnId|amount|currency|timestamp1// WRONG: amount in rupees, ms timestamp
2const input = `${mid}|${txn}|999|INR|${Date.now()}`;
3crypto.createHmac('sha256', API_KEY) // wrong key!
4 .update(input).digest('base64'); // wrong format!1// CORRECT: paise, seconds, SECRET key, hex
2const input = `${mid}|${txn}|99900|INR|${ts}`;
3crypto.createHmac('sha256', SECRET_KEY)
4 .update(input).digest('hex');API returns 400 with REQUEST_EXPIRED or INVALID_TIMESTAMP.
REQUEST_EXPIRED — Timestamp is older than 5 minutes. Your server clock may be out of sync.INVALID_TIMESTAMP — Timestamp is missing or more than 1 minute in the future.Date.now() instead of Math.floor(Date.now() / 1000)).Math.floor(Date.now() / 1000) in JavaScript (seconds, not milliseconds)int(time.time()) in Pythontime() in PHPsudo ntpdate pool.ntp.orgAPI returns 400 with a customer field validation error.
| Field | Rule |
|---|---|
customerPhone | 10-digit Indian mobile starting with 6, 7, 8, or 9 |
customerEmail | Valid email format. Disposable email providers are not allowed |
customerName | 2–100 characters, letters and spaces only |
200 within 10 seconds?301/302 redirect? Redirects are not followed and count as failures.Use ngrok to expose your local server:
1ngrok http 3000
2# Copy the HTTPS URL and register it in Dashboard200 quickly, even if processing fails. Process webhook events asynchronously. SabPaisa retries up to 10 times over ~8.5 minutes with exponential backoff. After that, webhooks go to the Dead Letter Queue.Transaction shows PENDING or PROCESSING but never reaches a terminal state.
| Status | Timeout | Result |
|---|---|---|
PENDING | 30 minutes | Auto → EXPIRED |
PROCESSING | 24 hours | Auto → TIMEOUT |
Use the Transaction Enquiry API or webhooks to monitor status:
1async function pollStatus(txnId) {
2 const res = await fetch(`/api/v2/payments/${txnId}/status`);
3 const data = await res.json();
4
5 const terminal = ['SUCCESS','FAILED','EXPIRED','TIMEOUT','CANCELLED'];
6 if (terminal.includes(data.status)) return data;
7
8 await new Promise(r => setTimeout(r, 3000));
9 return pollStatus(txnId);
10}JSON.stringify(req.body) instead of the raw request body (exact bytes)hex digest instead of base64crypto.timingSafeEqual)X-Webhook-Signature instead of X-SabPaisa-Signature1// WRONG: parsed body + hex + wrong header
2const sig = req.headers['x-webhook-signature'];
3const body = JSON.stringify(req.body);
4crypto.createHmac('sha256', secret)
5 .update(`${ts}.${body}`)
6 .digest('hex');1// CORRECT: raw body + base64 + right header
2const sig = req.headers['x-sabpaisa-signature'];
3const body = req.body.toString('utf8');
4crypto.createHmac('sha256', secret)
5 .update(`${ts}.${body}`)
6 .digest('base64');express.raw({ type: 'application/json' }) for the webhook route. The default JSON parser destroys the raw body needed for verification. See the complete Webhook Code Examples for all languages.SabPaisa guarantees at-least-once delivery. In rare cases (network issues, race conditions), you may receive the same webhook more than once. This is normal behavior, not a bug.
idempotency_key field from the payload (format: {txn_id}_{status})200 OK without re-processing200 OK for duplicate webhooks. Do NOT return an error status — this would trigger unnecessary retries.| Scope | Limit |
|---|---|
| Merchant API | 100,000 req/min |
| Checkout per IP | 30 req/min |
| Per payment (status checks) | 100 total |
Implement exponential backoff with jitter:
1async function retryWithBackoff(fn, retries = 3) {
2 for (let i = 0; i <= retries; i++) {
3 try { return await fn(); }
4 catch (e) {
5 if (i === retries || e.status !== 429) throw e;
6 const delay = Math.min(
7 1000 * 2 ** i + Math.random() * 1000,
8 30000
9 );
10 await new Promise(r => setTimeout(r, delay));
11 }
12 }
13}Check remaining refundable amount before issuing:
1// Check remaining refundable amount first
2const refunds = await fetch(
3 `/api/v2/refunds/transaction/${txnId}`,
4 { headers: { 'X-Api-Key': API_KEY } }
5).then(r => r.json());
6
7const remaining = refunds.originalAmount - refunds.totalRefunded;
8if (refundAmount > remaining) {
9 throw new Error(`Max: ${remaining} paise`);
10}merchantTxnId must be unique per payment attempt. You are reusing an existing ID.
Append timestamp to make IDs unique per attempt:
1// Unique per attempt, linked to your order
2const merchantTxnId = `ORD_${orderId}_${Date.now()}`;401 UNAUTHORIZED despite keys looking correctstaging-sb-merchant-api.sabpaisa.in — Production URL: merchant-api.sabpaisa.inUse environment variables to switch between environments:
1# Switch between staging and production
2SABPAISA_ENV=staging # or "production"
3SABPAISA_API_KEY=your_staging_key
4SABPAISA_SECRET_KEY=your_staging_secret
5
6# Your code picks the right base URL:
7# staging → https://staging-sb-merchant-api.sabpaisa.in
8# production → https://merchant-api.sabpaisa.inECONNRESET or ETIMEDOUT errors504 GATEWAY_TIMEOUT from the API1const response = await fetch(url, {
2 signal: AbortSignal.timeout(30000), // 30s timeout
3 headers: { 'X-Api-Key': API_KEY }
4});rahul@ppaytm instead of rahul@paytm)success@upi, failure@upi, or timeout@upi| Issue | Fix |
|---|---|
| Blank OTP iframe | Check if your site has restrictive Content-Security-Policy headers blocking bank domains |
| RuPay not working | RuPay uses REDIRECT mode (not OTP iframe). Ensure your integration handles both flows |
| 3DS popup blocked | Inform customers to disable popup blockers for the checkout domain |
| OTP timeout | Customer has 5 minutes to enter OTP. Show a countdown timer and retry option |
UNABLE_TO_VERIFY_LEAF_SIGNATURE or CERT_HAS_EXPIREDverify=False, rejectUnauthorized: false)sudo update-ca-certificatesNODE_TLS_REJECT_UNAUTHORIZED=0 in production. This disables all certificate verification and exposes you to man-in-the-middle attacks.When contacting support, include:
Was this page helpful?