Webhooks
Callback Format
When a transaction's status changes, we'll send a POST request to your configured callback URL. The payload is a JSON object that contains transaction details.
Always process the raw JSON as received. The payload structure may be extended with additional fields in the future.
Example Callback
{
"TransactionId": "txn_mhuph5pq",
"Status": "Captured",
"Amount": 25.00,
"Currency": "USD",
"Reference": "abc_1234567890",
"DeclineReason": null,
"CreatedAt": "2025-10-03T06:29:55.7233604Z",
"ProviderTransactionId": "ps_96f1df74-252c-456a-90eb-5c6f557b47b6",
"ProviderStatus": "Captured",
"ProviderMessage": "Payment completed successfully",
"UserDefinedField1": "Custom Value 1",
"UserDefinedField2": "Custom Value 2",
"UserDefinedField3": "Custom Value 3"
}
Always use the Status field as the authoritative source of truth for transaction state. While ProviderStatus and ProviderMessage provide additional context from the payment processor, your business logic should always be based on the Status field.
Decline Reasons
When a transaction has Status: "Declined" or Status: "Failed", the DeclineReason field provides details about why the transaction was not successful.
Common Decline Reasons
| Decline Reason | Description |
|---|---|
Transaction Expired | Customer did not complete the payment within the allowed time window |
Payment Cancelled By User | Customer explicitly cancelled the payment |
Declined By Bank | The issuing bank declined the transaction |
Insufficient Funds | Customer's account does not have sufficient balance |
Provider Error | The payment processor encountered a technical error |
Country Not Supported On Payment Method | The customer's country is not supported by the selected payment method |
Currency Not Supported | The transaction currency is not supported by the payment method |
Payment Method Not Available | The selected payment method is temporarily unavailable |
Invalid Card Details | Card number, CVV, or expiry date is invalid |
Card Expired | The payment card has expired |
3DS Authentication Failed | Customer failed 3D Secure verification |
Suspected Fraud | Transaction flagged by fraud detection system |
Daily Limit Exceeded | Transaction exceeds customer's daily spending limit |
Restricted Card | Card is restricted by the issuing bank |
Always display the DeclineReason to your customer when available, as it helps them understand what action to take (e.g., use a different card, contact their bank, etc.).
Security
All webhook requests are signed using your client secret. The signature is included in the X-ZepoPay-Signature header. We also include your client ID in the X-ZepoPay-Client-Id header for additional verification.
Verifying Webhooks
- cURL
- JavaScript
- Python
- Java
- Go
- Ruby
- C#
# Using OpenSSL to verify HMAC signature (Base64 format)
echo -n "$REQUEST_BODY" | openssl dgst -sha256 -hmac "your_client_secret" -binary | base64
import crypto from 'crypto';
import express from 'express';
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-zepopay-signature'];
const clientId = req.headers['x-zepopay-client-id'];
// Calculate signature using raw body (Base64 format)
const calculated_signature = crypto
.createHmac('sha256', 'your_client_secret')
.update(req.body)
.digest('base64');
if (signature !== calculated_signature) {
return res.status(400).send('Invalid signature');
}
// Verify client ID if needed
if (clientId !== 'your_client_id') {
return res.status(400).send('Invalid client ID');
}
// Process webhook asynchronously
const payload = JSON.parse(req.body);
processWebhook(payload);
res.status(200).send('OK');
});
import hmac
import hashlib
import base64
from flask import request
@app.route('/webhook', methods=['POST'])
def handle_webhook():
# Get the signature from headers
signature = request.headers.get('X-ZepoPay-Signature')
client_id = request.headers.get('X-ZepoPay-Client-Id')
# Get raw request body
payload = request.get_data()
# Calculate signature (Base64 format)
secret = b'your_client_secret'
calculated_signature = base64.b64encode(
hmac.new(secret, payload, hashlib.sha256).digest()
).decode('utf-8')
# Compare signatures
if not hmac.compare_digest(signature, calculated_signature):
return 'Invalid signature', 400
# Verify client ID if needed
if client_id != 'your_client_id':
return 'Invalid client ID', 400
# Process webhook asynchronously
process_webhook.delay(request.json)
return 'OK', 200
@RestController
public class WebhookController {
@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(
@RequestHeader("X-ZepoPay-Signature") String signature,
@RequestHeader("X-ZepoPay-Client-Id") String clientId,
@RequestBody String payload
) throws Exception {
// Calculate signature (Base64 format)
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
"your_client_secret".getBytes(),
"HmacSHA256"
);
sha256Hmac.init(secretKey);
String calculatedSignature = Base64.getEncoder()
.encodeToString(sha256Hmac.doFinal(payload.getBytes()));
if (!signature.equals(calculatedSignature)) {
return ResponseEntity.badRequest()
.body("Invalid signature");
}
// Verify client ID if needed
if (!"your_client_id".equals(clientId)) {
return ResponseEntity.badRequest()
.body("Invalid client ID");
}
// Process webhook asynchronously
asyncService.processWebhook(payload);
return ResponseEntity.ok("OK");
}
}
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
)
func handleWebhook(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-ZepoPay-Signature")
clientId := r.Header.Get("X-ZepoPay-Client-Id")
// Read raw body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Calculate signature (Base64 format)
mac := hmac.New(sha256.New, []byte("your_client_secret"))
mac.Write(body)
calculatedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(calculatedSignature)) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// Verify client ID if needed
if clientId != "your_client_id" {
http.Error(w, "Invalid client ID", http.StatusBadRequest)
return
}
// Process webhook asynchronously
go processWebhook(body)
w.WriteHeader(http.StatusOK)
}
require 'openssl'
require 'base64'
post '/webhook' do
request.body.rewind
payload_body = request.body.read
signature = request.env['HTTP_X_ZEPOPAY_SIGNATURE']
client_id = request.env['HTTP_X_ZEPOPAY_CLIENT_ID']
# Calculate signature (Base64 format)
calculated_signature = Base64.strict_encode64(
OpenSSL::HMAC.digest(
'SHA256',
'your_client_secret',
payload_body
)
)
halt 400, 'Invalid signature' unless Rack::Utils.secure_compare(
signature,
calculated_signature
)
# Verify client ID if needed
halt 400, 'Invalid client ID' unless client_id == 'your_client_id'
# Process webhook asynchronously
WebhookWorker.perform_async(payload_body)
status 200
end
[ApiController]
public class WebhookController : ControllerBase
{
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook()
{
// Get the signature and client ID
var signature = Request.Headers["X-ZepoPay-Signature"].ToString();
var clientId = Request.Headers["X-ZepoPay-Client-Id"].ToString();
// Get raw request body
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
// Calculate signature (Base64 format)
var secretBytes = Encoding.UTF8.GetBytes("your_client_secret");
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
var calculatedSignature = Convert.ToBase64String(
hmac.ComputeHash(payloadBytes)
);
if (signature != calculatedSignature)
{
return BadRequest("Invalid signature");
}
// Verify client ID if needed
if (clientId != "your_client_id")
{
return BadRequest("Invalid client ID");
}
// Process webhook asynchronously
await _backgroundJobs.Enqueue(() =>
ProcessWebhook(JsonDocument.Parse(payload)));
return Ok();
}
}
Status Messages
| Status | Description |
|---|---|
| Pending | The transaction is awaiting processing |
| Captured | The transaction has been successfully completed |
| Declined | The transaction was declined by the payment processor |
| Refunded | The transaction has been refunded to the customer |
| Failed | The transaction could not be processed successfully |
| Authorized | The payment has been authorized but not yet captured |
| Chargeback | The customer has disputed the transaction and requested a chargeback |
Best Practices
1. Response Timing
- Return HTTP 200 within 5 seconds to prevent retries
- Acknowledge receipt immediately, process later
2. Idempotency & Deduplication
- Check
TransactionIdagainst processed webhooks in your database - Handle duplicate deliveries gracefully (same transaction may trigger multiple webhooks)
- Use database constraints or locks to prevent double-processing
3. Security Validation
- Always verify
X-ZepoPay-Signatureusing HMAC-SHA256 with raw body - Validate
X-ZepoPay-Client-Idmatches your credentials - Reject requests with invalid/missing signatures (return 400/401)
4. Data Handling
- Parse JSON only after signature verification
- Store raw webhook payload for audit/debugging
- Log verification failures with timestamp and IP
5. Retry & Error Handling
- Return 200 only after successfully queuing for processing
- Non-200 responses trigger exponential backoff retries
- Alert on repeated failures for same transaction
Testing Webhooks
Local Development
- Use tunneling tools (ngrok, localtunnel, Cloudflare Tunnel) to expose localhost
- Ensure your endpoint is publicly accessible over HTTPS
- Configure the tunnel URL as your webhook endpoint in sandbox
Sandbox Environment
- Create test transactions with small amounts to trigger webhook events
- Monitor webhook delivery logs in your dashboard
- Test all transaction states: Pending, Captured, Declined, Failed
- Verify signature validation works with sandbox credentials
Testing Checklist
- ✅ Signature verification passes for valid requests
- ✅ Signature verification fails for tampered payloads
- ✅ Duplicate webhooks are handled idempotently
- ✅ Endpoint returns 200 within timeout window
- ✅ Failed processing jobs retry correctly
- ✅ Error logs capture failed verifications