Receive real-time payment notifications via HTTP POST webhooks.
SabPaisa PG 3.0 sends real-time HTTP POST notifications (webhooks) to your server whenever a payment reaches a terminal state. This eliminates the need for polling and ensures your system stays in sync with payment outcomes.
1Customer Payment → SabPaisa PG 3.0 → Payment Processed → Webhook POST → Your ServerWebhooks are sent for the following terminal payment states: Success Failed Expired Timeout
https://api.yoursite.com/webhooks/sabpaisa).200 OK response to acknowledge receipt.| Requirement | Details |
|---|---|
| Protocol | HTTPS only (TLS 1.2+) |
| Method | Must accept POST requests |
| Content-Type | Receives application/json |
| Response Time | Must respond within 10 seconds |
| Response Code | Return 2xx (200-299) to acknowledge |
| Availability | Endpoint must be publicly accessible |
Each webhook carries an event field indicating the payment outcome:
| Event | Status | Description |
|---|---|---|
| payment.success | SUCCESS | Payment was successfully completed. Funds have been captured. |
| payment.failed | FAILED | Payment failed due to insufficient funds, bank decline, authentication failure, etc. |
| payment.expired | EXPIRED | Payment session expired. The customer did not complete payment within the allowed time window. |
| payment.timeout | TIMEOUT | Payment processing timed out at the bank/aggregator level. |
Every webhook sends a JSON payload with the following structure:
1{
2 "event": "payment.success",
3 "txn_id": "TXN202602150001",
4 "merchant_txn_id": "ORDER-12345",
5 "status": "SUCCESS",
6 "request_amount": 1500.00,
7 "paid_amount": 1500.00,
8 "currency": "INR",
9 "payment_mode": "UPI",
10 "bank_txn_id": "BANK123456789",
11 "bank_rrn": "432109876543",
12 "completed_at": "2026-02-15T10:30:00Z",
13 "timestamp": "2026-02-15T10:30:01.234Z",
14 "idempotency_key": "TXN202602150001_SUCCESS",
15 "udf_data": {
16 "udf1": "custom-value-1",
17 "udf2": "custom-value-2"
18 },
19 "metadata": {
20 "key1": "value1"
21 }
22}| Field | Type | Always Present | Description |
|---|---|---|---|
| event | string | Yes | Event type: payment.success, payment.failed, payment.expired, payment.timeout |
| txn_id | string | Yes | SabPaisa unique transaction ID |
| merchant_txn_id | string | Yes | Your order/transaction reference ID (passed during payment initiation) |
| status | string | Yes | Terminal status: SUCCESS, FAILED, EXPIRED, TIMEOUT |
| request_amount | number | Yes | Original requested payment amount |
| paid_amount | number | Conditional | Actual amount paid (present for SUCCESS; may differ in partial payment scenarios) |
| currency | string | Yes | Currency code (e.g., INR) |
| payment_mode | string | Conditional | Payment method: UPI, NETBANKING, CARD, WALLET, etc. |
| bank_txn_id | string | Conditional | Bank's transaction reference ID (when provided by bank) |
| bank_rrn | string | Conditional | Bank's Retrieval Reference Number |
| completed_at | string (ISO 8601) | Conditional | Timestamp when the payment reached terminal state |
| timestamp | string (ISO 8601) | Yes | Timestamp when this webhook was generated |
| idempotency_key | string | Yes | Unique key for this event: {txn_id}_{status}. Use to prevent duplicate processing. |
| udf_data | object | Conditional | User-defined fields passed during payment initiation |
| metadata | object | Conditional | Additional metadata associated with the transaction |
1{
2 "event": "payment.success",
3 "txn_id": "TXN202602150001",
4 "merchant_txn_id": "ORDER-12345",
5 "status": "SUCCESS",
6 "request_amount": 1500.00,
7 "paid_amount": 1500.00,
8 "currency": "INR",
9 "payment_mode": "UPI",
10 "bank_txn_id": "BANK123456789",
11 "bank_rrn": "432109876543",
12 "completed_at": "2026-02-15T10:30:00Z",
13 "timestamp": "2026-02-15T10:30:01.234Z",
14 "idempotency_key": "TXN202602150001_SUCCESS"
15}1{
2 "event": "payment.failed",
3 "txn_id": "TXN202602150002",
4 "merchant_txn_id": "ORDER-12346",
5 "status": "FAILED",
6 "request_amount": 2000.00,
7 "paid_amount": null,
8 "currency": "INR",
9 "payment_mode": "NETBANKING",
10 "bank_txn_id": null,
11 "bank_rrn": null,
12 "completed_at": "2026-02-15T11:00:00Z",
13 "timestamp": "2026-02-15T11:00:00.567Z",
14 "idempotency_key": "TXN202602150002_FAILED"
15}1{
2 "event": "payment.expired",
3 "txn_id": "TXN202602150003",
4 "merchant_txn_id": "ORDER-12347",
5 "status": "EXPIRED",
6 "request_amount": 500.00,
7 "paid_amount": null,
8 "currency": "INR",
9 "payment_mode": null,
10 "bank_txn_id": null,
11 "bank_rrn": null,
12 "completed_at": null,
13 "timestamp": "2026-02-15T11:30:00.789Z",
14 "idempotency_key": "TXN202602150003_EXPIRED"
15}1{
2 "event": "payment.timeout",
3 "txn_id": "TXN202602150004",
4 "merchant_txn_id": "ORDER-12348",
5 "status": "TIMEOUT",
6 "request_amount": 3000.00,
7 "paid_amount": null,
8 "currency": "INR",
9 "payment_mode": "CARD",
10 "bank_txn_id": null,
11 "bank_rrn": null,
12 "completed_at": null,
13 "timestamp": "2026-02-15T12:00:00.123Z",
14 "idempotency_key": "TXN202602150004_TIMEOUT"
15}Every webhook request includes the following custom headers:
| Header | Description | Example |
|---|---|---|
| Content-Type | Always application/json | application/json |
| X-SabPaisa-Signature | HMAC-SHA256 signature for payload verification (format: timestamp.signature) | 1708000000000.dGhpcyBp... |
| X-SabPaisa-Timestamp | Unix timestamp (milliseconds) when the request was sent | 1708000000000 |
| X-SabPaisa-Event | The event type | payment.success |
| X-SabPaisa-Delivery-Id | Unique delivery ID for this webhook attempt (useful for debugging) | 42981 |
Every webhook must be verified before processing. The X-SabPaisa-Signature header contains an HMAC-SHA256 signature that proves the webhook was sent by SabPaisa and has not been tampered with.
1X-SabPaisa-Signature: {timestamp}.{base64_signature}
2
3Where:
4 timestamp = Unix timestamp in milliseconds when the signature was created
5 base64_signature = Base64-encoded HMAC-SHA256 hash. to get [timestamp, received_signature]{timestamp}.{raw_request_body}HMAC-SHA256(signed_string, your_secret_key)1Input:
2 raw_body = the raw JSON request body (exact bytes, do NOT parse and re-serialize)
3 secret_key = your webhook secret key (provided by SabPaisa)
4 signature_header = value of X-SabPaisa-Signature header
5
6Step 1: Split signature_header by "."
7 → timestamp = "1708000000000"
8 → received_sig = "dGhpcyBpcyBhIHNhbXBsZQ=="
9
10Step 2: Validate timestamp
11 → abs(current_time_ms - timestamp) must be <= 300000 (5 minutes)
12 → If expired, reject the webhook (possible replay attack)
13
14Step 3: Build the string to sign
15 → to_sign = timestamp + "." + raw_body
16 → e.g., "1708000000000.{\"event\":\"payment.success\",...}"
17
18Step 4: Compute HMAC-SHA256
19 → hmac = HMAC-SHA256(to_sign, secret_key)
20 → expected_sig = Base64Encode(hmac)
21
22Step 5: Compare signatures (constant-time)
23 → expected_sig == received_sig → VALID
24 → expected_sig != received_sig → REJECTcrypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, MessageDigest.isEqual in Java) to prevent timing attacks.Return any 2xx HTTP status code to acknowledge successful receipt. The response body is optional.
| Your Response | SabPaisa Action |
|---|---|
| 2xx (200-299) | Delivery marked as successful. No retry. |
| 3xx (300-399) | Treated as failure. Will retry. |
| 4xx (400-499) | Treated as failure. Will retry. |
| 5xx (500-599) | Treated as failure. Will retry. |
| Connection refused / DNS error | Treated as failure. Will retry. |
| Response timeout (>10 seconds) | Treated as failure. Will retry. |
200 OK immediately, then process the payment update in a background job.200 OK both times (use idempotency to skip re-processing internally).SabPaisa guarantees at-least-once delivery. Every webhook will be delivered at least once. In rare cases, you may receive the same webhook more than once. Your integration must be idempotent.
If your endpoint does not return a 2xx response, SabPaisa will retry with exponential backoff and jitter:
After 10 failed attempts, the webhook is moved to a Dead Letter Queue. The SabPaisa operations team will be alerted and can manually retry the delivery. If you experience extended downtime, contact SabPaisa support to arrange a bulk retry of missed webhooks.
If your endpoint consistently fails (>50% failure rate over 20 requests), SabPaisa will temporarily pause webhook deliveries to your endpoint for 5 minutes to prevent overloading your server. Deliveries will automatically resume after the cooldown period.
Since SabPaisa guarantees at-least-once delivery, your webhook handler must be idempotent — processing the same webhook multiple times should produce the same result.
Every webhook includes an idempotency_key field in the format: {txn_id}_{status}(e.g., TXN202602150001_SUCCESS)
11. Receive webhook
22. Verify signature
33. Extract idempotency_key from payload
44. Check if this idempotency_key has been processed before (database/cache lookup)
5 - If YES → Return 200 OK (skip processing, already handled)
6 - If NO → Process the webhook, then store the idempotency_key
75. Return 200 OK| Approach | Pros | Cons |
|---|---|---|
| Database table | Durable, survives restarts | Slightly slower |
| Redis SET with TTL | Fast, auto-expiry | Lost on Redis restart |
| Both | Best of both worlds | More complexity |
idempotency_key values for at least 24 hours.Complete webhook handler implementations with signature verification, idempotency checks, and async processing.
1import javax.crypto.Mac;
2import javax.crypto.spec.SecretKeySpec;
3import java.nio.charset.StandardCharsets;
4import java.security.MessageDigest;
5import java.time.Instant;
6import java.util.Base64;
7import java.util.Map;
8
9import org.springframework.http.ResponseEntity;
10import org.springframework.web.bind.annotation.*;
11
12@RestController
13@RequestMapping("/webhooks/sabpaisa")
14public class SabPaisaWebhookController {
15
16 private static final String WEBHOOK_SECRET = "your_webhook_secret_key";
17
18 @PostMapping
19 public ResponseEntity<Map<String, String>> handleWebhook(
20 @RequestBody String rawBody,
21 @RequestHeader("X-SabPaisa-Signature") String signatureHeader,
22 @RequestHeader("X-SabPaisa-Event") String eventType) {
23
24 // Step 1: Verify signature
25 if (!verifySignature(rawBody, signatureHeader, WEBHOOK_SECRET)) {
26 return ResponseEntity.status(401)
27 .body(Map.of("error", "Invalid signature"));
28 }
29
30 // Step 2: Parse and process (use async in production)
31 System.out.println("Received webhook: " + eventType);
32
33 // Step 3: Acknowledge
34 return ResponseEntity.ok(Map.of("status", "received"));
35 }
36
37 private boolean verifySignature(String rawBody, String signatureHeader, String secret) {
38 try {
39 String[] parts = signatureHeader.split("\\.");
40 if (parts.length != 2) return false;
41
42 long timestamp = Long.parseLong(parts[0]);
43 String receivedSignature = parts[1];
44
45 // Check timestamp freshness (5-minute tolerance)
46 long now = Instant.now().toEpochMilli();
47 if (Math.abs(now - timestamp) > 300000) return false;
48
49 // Compute expected signature
50 String toSign = timestamp + "." + rawBody;
51 Mac hmac = Mac.getInstance("HmacSHA256");
52 SecretKeySpec keySpec = new SecretKeySpec(
53 secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
54 hmac.init(keySpec);
55 byte[] hash = hmac.doFinal(toSign.getBytes(StandardCharsets.UTF_8));
56 String expectedSignature = Base64.getEncoder().encodeToString(hash);
57
58 // Constant-time comparison
59 return MessageDigest.isEqual(
60 expectedSignature.getBytes(StandardCharsets.UTF_8),
61 receivedSignature.getBytes(StandardCharsets.UTF_8));
62 } catch (Exception e) {
63 return false;
64 }
65 }
66}1<?php
2
3$webhookSecret = getenv('SABPAISA_WEBHOOK_SECRET');
4
5// Step 1: Read raw body
6$rawBody = file_get_contents('php://input');
7$signatureHeader = $_SERVER['HTTP_X_SABPAISA_SIGNATURE'] ?? '';
8
9// Step 2: Verify signature
10if (!verifySignature($rawBody, $signatureHeader, $webhookSecret)) {
11 http_response_code(401);
12 echo json_encode(['error' => 'Invalid signature']);
13 exit;
14}
15
16// Step 3: Parse payload
17$payload = json_decode($rawBody, true);
18
19// Step 4: Check idempotency
20$idempotencyKey = $payload['idempotency_key'];
21// if (isAlreadyProcessed($idempotencyKey)) {
22// http_response_code(200);
23// echo json_encode(['status' => 'already_processed']);
24// exit;
25// }
26
27// Step 5: Process event
28switch ($payload['event']) {
29 case 'payment.success':
30 error_log("Payment SUCCESS: " . $payload['txn_id']);
31 break;
32 case 'payment.failed':
33 error_log("Payment FAILED: " . $payload['txn_id']);
34 break;
35 case 'payment.expired':
36 error_log("Payment EXPIRED: " . $payload['txn_id']);
37 break;
38 case 'payment.timeout':
39 error_log("Payment TIMEOUT: " . $payload['txn_id']);
40 break;
41}
42
43// Step 6: Acknowledge
44http_response_code(200);
45echo json_encode(['status' => 'received']);
46
47function verifySignature(string $rawBody, string $signatureHeader, string $secret): bool {
48 if (empty($signatureHeader)) return false;
49
50 $parts = explode('.', $signatureHeader, 2);
51 if (count($parts) !== 2) return false;
52
53 [$timestamp, $receivedSignature] = $parts;
54
55 // Check timestamp freshness (5-minute tolerance)
56 $now = round(microtime(true) * 1000);
57 if (abs($now - intval($timestamp)) > 300000) return false;
58
59 // Compute expected signature
60 $toSign = $timestamp . '.' . $rawBody;
61 $expectedSignature = base64_encode(
62 hash_hmac('sha256', $toSign, $secret, true)
63 );
64
65 // Constant-time comparison
66 return hash_equals($expectedSignature, $receivedSignature);
67}1using System.Security.Cryptography;
2using System.Text;
3using Microsoft.AspNetCore.Mvc;
4
5[ApiController]
6[Route("webhooks/sabpaisa")]
7public class SabPaisaWebhookController : ControllerBase
8{
9 private const string WebhookSecret = "your_webhook_secret_key";
10
11 [HttpPost]
12 public IActionResult HandleWebhook(
13 [FromHeader(Name = "X-SabPaisa-Signature")] string signatureHeader)
14 {
15 // Read raw body
16 using var reader = new StreamReader(Request.Body, Encoding.UTF8);
17 var rawBody = reader.ReadToEndAsync().Result;
18
19 // Verify signature
20 if (!VerifySignature(rawBody, signatureHeader, WebhookSecret))
21 return Unauthorized(new { error = "Invalid signature" });
22
23 // Process webhook asynchronously...
24
25 return Ok(new { status = "received" });
26 }
27
28 private static bool VerifySignature(string rawBody, string signatureHeader, string secret)
29 {
30 var parts = signatureHeader.Split('.', 2);
31 if (parts.Length != 2) return false;
32
33 if (!long.TryParse(parts[0], out var timestamp)) return false;
34 var receivedSignature = parts[1];
35
36 // Check timestamp freshness
37 var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
38 if (Math.Abs(now - timestamp) > 300000) return false;
39
40 // Compute expected signature
41 var toSign = $"{timestamp}.{rawBody}";
42 using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
43 var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(toSign));
44 var expectedSignature = Convert.ToBase64String(hash);
45
46 // Constant-time comparison
47 return CryptographicOperations.FixedTimeEquals(
48 Encoding.UTF8.GetBytes(expectedSignature),
49 Encoding.UTF8.GetBytes(receivedSignature));
50 }
51}1package main
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "io"
10 "math"
11 "net/http"
12 "strconv"
13 "strings"
14 "time"
15)
16
17const webhookSecret = "your_webhook_secret_key"
18
19func main() {
20 http.HandleFunc("/webhooks/sabpaisa", handleWebhook)
21 fmt.Println("Webhook server running on :3000")
22 http.ListenAndServe(":3000", nil)
23}
24
25func handleWebhook(w http.ResponseWriter, r *http.Request) {
26 // Read raw body
27 rawBody, err := io.ReadAll(r.Body)
28 if err != nil {
29 http.Error(w, "Failed to read body", http.StatusBadRequest)
30 return
31 }
32
33 // Verify signature
34 signatureHeader := r.Header.Get("X-SabPaisa-Signature")
35 if !verifySignature(string(rawBody), signatureHeader, webhookSecret) {
36 http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
37 return
38 }
39
40 // Parse payload
41 var payload map[string]interface{}
42 json.Unmarshal(rawBody, &payload)
43
44 event := payload["event"].(string)
45 txnID := payload["txn_id"].(string)
46 fmt.Printf("Received %s for txn %s\n", event, txnID)
47
48 // Acknowledge
49 w.Header().Set("Content-Type", "application/json")
50 w.WriteHeader(http.StatusOK)
51 w.Write([]byte(`{"status":"received"}`))
52}
53
54func verifySignature(rawBody, signatureHeader, secret string) bool {
55 parts := strings.SplitN(signatureHeader, ".", 2)
56 if len(parts) != 2 {
57 return false
58 }
59
60 timestamp, err := strconv.ParseInt(parts[0], 10, 64)
61 if err != nil {
62 return false
63 }
64 receivedSignature := parts[1]
65
66 // Check timestamp freshness
67 now := time.Now().UnixMilli()
68 if math.Abs(float64(now-timestamp)) > 300000 {
69 return false
70 }
71
72 // Compute expected signature
73 toSign := fmt.Sprintf("%d.%s", timestamp, rawBody)
74 mac := hmac.New(sha256.New, []byte(secret))
75 mac.Write([]byte(toSign))
76 expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
77
78 // Constant-time comparison
79 return hmac.Equal([]byte(expectedSignature), []byte(receivedSignature))
80}Simulate a webhook locally for testing (without signature verification):
1curl -X POST http://localhost:3000/webhooks/sabpaisa \
2 -H "Content-Type: application/json" \
3 -H "X-SabPaisa-Event: payment.success" \
4 -H "X-SabPaisa-Timestamp: $(date +%s000)" \
5 -H "X-SabPaisa-Delivery-Id: test-001" \
6 -d '{
7 "event": "payment.success",
8 "txn_id": "TEST_TXN_001",
9 "merchant_txn_id": "TEST_ORDER_001",
10 "status": "SUCCESS",
11 "request_amount": 100.00,
12 "paid_amount": 100.00,
13 "currency": "INR",
14 "payment_mode": "UPI",
15 "bank_txn_id": "BANK_TEST_001",
16 "bank_rrn": "RRN_TEST_001",
17 "completed_at": "2026-02-15T10:00:00Z",
18 "timestamp": "2026-02-15T10:00:01Z",
19 "idempotency_key": "TEST_TXN_001_SUCCESS"
20 }'Before going live, verify the following:
| Issue | Possible Cause | Solution |
|---|---|---|
| Signature verification fails | Parsing/re-serializing JSON body before verification | Use the raw request body (exact bytes) for signature computation |
| Signature verification fails | Server clock is out of sync | Sync server time via NTP. Tolerance window is 5 minutes. |
| Webhook not received | Endpoint not publicly accessible | Ensure your URL is reachable from the internet (not behind VPN/firewall) |
| Webhook not received | Firewall blocking SabPaisa IPs | Contact SabPaisa support to get webhook source IPs for whitelisting |
| Receiving duplicate webhooks | Normal at-least-once delivery behavior | Implement idempotency using the idempotency_key field |
| Webhook delivery paused | Circuit breaker triggered (>50% failure rate) | Fix endpoint issues. Delivery auto-resumes after 5 minutes. |
| Timeout errors | Endpoint taking too long to respond | Acknowledge with 200 OK immediately; process asynchronously |
| Getting 4xx/5xx from server | Application error in webhook handler | Check server logs. Ensure the endpoint accepts POST with JSON body. |
X-SabPaisa-Delivery-Id from the webhook headers and the txn_id from the payload. Contact SabPaisa support with these details for investigation.A: Yes. Contact the SabPaisa integration team to update your webhook URL. The change takes effect within minutes.
A: Currently, all terminal payment events are sent. Filter events in your handler by checking the event field.
A: Webhooks will retry for approximately 8.5 minutes (10 attempts). After that, they are moved to the Dead Letter Queue. Contact SabPaisa support to arrange a bulk re-delivery once your server is back online.
A: The payload is sent over HTTPS (TLS-encrypted in transit). The payload content itself is not encrypted but is signed with HMAC-SHA256 for integrity and authenticity verification.
A: Currently, one webhook URL per merchant is supported. If you need to route to multiple services, implement a fan-out pattern on your webhook receiver.
A: Webhook payloads are typically under 2 KB. The maximum response body your server sends back can be up to 512 KB (though only the HTTP status code matters).
A: No. Only the HTTP status code matters. A 200 OK with an empty body is perfectly valid.
A: Redirects are not followed. They are treated as failures and will trigger a retry. Ensure your webhook URL is the final destination.
A: Contact SabPaisa support to rotate your secret key. During rotation, both old and new keys will be active for a brief transition period.