Appearance
Webhooks Overview
Webhooks allow you to receive real-time notifications when deposit events occur in your merchant account.
How Webhooks Work
- Configure callback URL: Set your
callback_urlwhen creating your merchant account - Event occurs: Deposit settled, expired, or closed
- Webhook sent: We POST event data to your callback URL
- You respond: Return 200 OK to acknowledge receipt
- Retries: If failed, we retry with exponential backoff
Callback URL Requirements
- HTTPS recommended: Use HTTPS for production environments
- Public endpoint: Must be accessible from the internet
- Fast response: Respond quickly to acknowledge receipt
- 200 OK: Return 200 status code to acknowledge receipt
Webhook Payload
All webhooks are sent as POST requests with JSON payload and HMAC signature:
http
POST /your-callback-endpoint HTTP/1.1
Host: yourdomain.com
Content-Type: application/json
X-Signature: 7c8f9e0d1b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b...
{
"success": true,
"code": 200,
"data": {
"trx_ref": "TRX-PROVIDER-123",
"amount": 100,
"currency": "USDT",
"fee": 1.5,
"invoice_reference": "PAYIN-ABCD123456",
"out_trade_no": "MERCHANT-ORDER-001",
"status": "success"
}
}Signature Verification
Every webhook request includes an HMAC signature for verification:
| Header | Description |
|---|---|
X-Signature | HMAC-SHA256 signature of the JSON payload (header name configurable per merchant) |
How to verify:
- Get the signature from the
X-Signatureheader - Compute HMAC-SHA256 of the raw JSON body using your
hmac_secret - Compare the computed signature with the received signature
Node.js Example
javascript
const crypto = require('crypto');
function verifySignature(req, hmacSecret) {
const signature = req.headers['x-signature'];
const payload = JSON.stringify(req.body);
const expectedSignature = crypto
.createHmac('sha256', hmacSecret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}PHP Example
php
function verifySignature(Request $request, string $hmacSecret): bool
{
$signature = $request->header('X-Signature');
$payload = $request->getContent();
$expectedSignature = hash_hmac('sha256', $payload, $hmacSecret);
return hash_equals($expectedSignature, $signature);
}Python Example
python
import hmac
import hashlib
def verify_signature(request_body: bytes, signature: str, hmac_secret: str) -> bool:
expected_signature = hmac.new(
hmac_secret.encode('utf-8'),
request_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature)
# Flask example
from flask import request
@app.route('/webhooks/payin', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Signature', '')
if not verify_signature(request.data, signature, HMAC_SECRET):
return {'error': 'Invalid signature'}, 401
data = request.json['data']
# Process webhook...
return {'received': True}, 200Java Example
java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
public class WebhookVerifier {
public static boolean verifySignature(String payload, String signature, String hmacSecret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
hmacSecret.getBytes("UTF-8"),
"HmacSHA256"
);
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(payload.getBytes("UTF-8"));
String expectedSignature = bytesToHex(hash);
return MessageDigest.isEqual(
expectedSignature.getBytes(),
signature.getBytes()
);
} catch (Exception e) {
return false;
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
}
// Spring Boot example
@RestController
public class WebhookController {
@Value("${hmac.secret}")
private String hmacSecret;
@PostMapping("/webhooks/payin")
public ResponseEntity<?> handleWebhook(
@RequestBody String payload,
@RequestHeader("X-Signature") String signature) {
if (!WebhookVerifier.verifySignature(payload, signature, hmacSecret)) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
}
// Process webhook...
return ResponseEntity.ok(Map.of("received", true));
}
}Go Example
go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func verifySignature(payload []byte, signature string, hmacSecret string) bool {
mac := hmac.New(sha256.New, []byte(hmacSecret))
mac.Write(payload)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expectedSignature), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Signature")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
if !verifySignature(body, signature, hmacSecret) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid signature"})
return
}
// Process webhook...
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}Rust Example
rust
use hmac::{Hmac, Mac};
use sha2::Sha256;
use hex;
type HmacSha256 = Hmac<Sha256>;
fn verify_signature(payload: &[u8], signature: &str, hmac_secret: &str) -> bool {
let mut mac = HmacSha256::new_from_slice(hmac_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload);
let expected_signature = hex::encode(mac.finalize().into_bytes());
constant_time_compare(expected_signature.as_bytes(), signature.as_bytes())
}
fn constant_time_compare(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).fold(0, |acc, (x, y)| acc | (x ^ y)) == 0
}
// Actix-web example
use actix_web::{post, web, HttpRequest, HttpResponse};
#[post("/webhooks/payin")]
async fn handle_webhook(
req: HttpRequest,
body: web::Bytes,
) -> HttpResponse {
let signature = req
.headers()
.get("X-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let hmac_secret = std::env::var("HMAC_SECRET").unwrap();
if !verify_signature(&body, signature, &hmac_secret) {
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid signature"}));
}
// Process webhook...
HttpResponse::Ok().json(serde_json::json!({"received": true}))
}C# Example
csharp
using System.Security.Cryptography;
using System.Text;
public class WebhookVerifier
{
public static bool VerifySignature(string payload, string signature, string hmacSecret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(hmacSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expectedSignature = Convert.ToHexString(hash).ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSignature),
Encoding.UTF8.GetBytes(signature)
);
}
}
// ASP.NET Core example
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private readonly string _hmacSecret;
public WebhookController(IConfiguration config)
{
_hmacSecret = config["HmacSecret"];
}
[HttpPost("payin")]
public async Task<IActionResult> HandleWebhook()
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
var signature = Request.Headers["X-Signature"].ToString();
if (!WebhookVerifier.VerifySignature(payload, signature, _hmacSecret))
{
return Unauthorized(new { error = "Invalid signature" });
}
// Process webhook...
return Ok(new { received = true });
}
}Ruby Example
ruby
require 'openssl'
def verify_signature(payload, signature, hmac_secret)
expected_signature = OpenSSL::HMAC.hexdigest('SHA256', hmac_secret, payload)
Rack::Utils.secure_compare(expected_signature, signature)
end
# Rails example
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def payin
signature = request.headers['X-Signature']
payload = request.raw_post
unless verify_signature(payload, signature, ENV['HMAC_SECRET'])
return render json: { error: 'Invalid signature' }, status: :unauthorized
end
data = JSON.parse(payload)['data']
# Process webhook...
render json: { received: true }
end
endPayload Fields
The webhook payload uses the same format as API responses for unified handling:
| Field | Type | Description |
|---|---|---|
success | boolean | Always true for webhook callbacks |
code | integer | Always 200 for webhook callbacks |
data | object | The event data (same structure as API response) |
Data object fields:
| Field | Type | Description |
|---|---|---|
trx_ref | string | Provider transaction reference (tx_hash) |
amount | number | Payment amount (same as input) |
currency | string | Currency code (e.g., USDT, USD) |
fee | number | Fee amount |
invoice_reference | string | System-generated invoice reference |
out_trade_no | string | Your order reference (if provided in payin request) |
status | string | Payment status (success, expired, close) |
Example Implementation
Node.js/Express
javascript
const express = require('express');
const crypto = require('crypto');
const app = express();
const HMAC_SECRET = 'your_hmac_secret';
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
function verifySignature(req) {
const signature = req.headers['x-signature'];
const expectedSignature = crypto
.createHmac('sha256', HMAC_SECRET)
.update(req.rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature || ''),
Buffer.from(expectedSignature)
);
}
app.post('/webhooks/payin', (req, res) => {
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Same format as API response - unified handling
const { success, code, data } = req.body;
const { trx_ref, amount_cents, invoice_reference, out_trade_no, status } = data;
console.log('Deposit received:', amount_cents / 100, 'USD');
console.log('Transaction Ref:', trx_ref);
console.log('Order:', out_trade_no);
console.log('Status:', status);
// Update your order status
// await updateOrderStatus(out_trade_no, status);
res.status(200).json({ received: true });
});
app.listen(3000);PHP/Laravel
php
Route::post('/webhooks/payin', function (Request $request) {
$hmacSecret = config('services.portal.hmac_secret');
$signature = $request->header('X-Signature');
$payload = $request->getContent();
$expectedSignature = hash_hmac('sha256', $payload, $hmacSecret);
if (!hash_equals($expectedSignature, $signature ?? '')) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Same format as API response - unified handling
$body = $request->all();
$data = $body['data'];
Log::info('Deposit received', [
'trx_ref' => $data['trx_ref'],
'amount' => $data['amount_cents'] / 100,
'invoice' => $data['invoice_reference'],
'order' => $data['out_trade_no'],
'status' => $data['status'],
]);
// Update your order status
// Order::where('order_no', $data['out_trade_no'])->update(['status' => $data['status']]);
return response()->json(['received' => true]);
});Query Status API
If you miss a webhook or need to verify payment status, use the status endpoint:
Endpoint: POST /api/v1/payins/status
Request:
json
{
"out_trade_no": "MERCHANT-ORDER-001"
}Response:
json
{
"success": true,
"code": 200,
"data": {
"trx_ref": "TRX-PROVIDER-123",
"amount": 100,
"currency": "USDT",
"fee": 1.5,
"invoice_reference": "PAYIN-ABCD123456",
"out_trade_no": "MERCHANT-ORDER-001",
"status": "success"
}
}Status Values
| Status | Description |
|---|---|
waiting | Payment pending, awaiting user payment |
success | Payment confirmed and wallet credited |
expired | Payment expired without completion |
close | Payment closed/cancelled |
Best Practices
Do
- Always verify the signature before processing webhooks
- Return 200 OK quickly to acknowledge receipt
- Use
out_trade_noto match webhooks to your orders - Store
invoice_referenceandtrx_reffor reference - Use the status API to verify if uncertain
Don't
- Process long-running tasks before responding
- Return non-200 status codes for valid webhooks
- Skip signature verification in production
Troubleshooting
Webhooks not received
- Verify callback URL is correct and publicly accessible
- Check firewall/security group settings
- Ensure endpoint returns 200 OK
Missed webhook
- Use
POST /api/v1/payins/statusto query current status - Pass your
out_trade_noto retrieve payment status