Skip to content

Webhooks Overview

Webhooks allow you to receive real-time notifications when deposit events occur in your merchant account.

How Webhooks Work

  1. Configure callback URL: Set your callback_url when creating your merchant account
  2. Event occurs: Deposit settled, expired, or closed
  3. Webhook sent: We POST event data to your callback URL
  4. You respond: Return 200 OK to acknowledge receipt
  5. 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:

HeaderDescription
X-SignatureHMAC-SHA256 signature of the JSON payload (header name configurable per merchant)

How to verify:

  1. Get the signature from the X-Signature header
  2. Compute HMAC-SHA256 of the raw JSON body using your hmac_secret
  3. 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}, 200

Java 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
end

Payload Fields

The webhook payload uses the same format as API responses for unified handling:

FieldTypeDescription
successbooleanAlways true for webhook callbacks
codeintegerAlways 200 for webhook callbacks
dataobjectThe event data (same structure as API response)

Data object fields:

FieldTypeDescription
trx_refstringProvider transaction reference (tx_hash)
amountnumberPayment amount (same as input)
currencystringCurrency code (e.g., USDT, USD)
feenumberFee amount
invoice_referencestringSystem-generated invoice reference
out_trade_nostringYour order reference (if provided in payin request)
statusstringPayment 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

StatusDescription
waitingPayment pending, awaiting user payment
successPayment confirmed and wallet credited
expiredPayment expired without completion
closePayment closed/cancelled

Best Practices

Do

  • Always verify the signature before processing webhooks
  • Return 200 OK quickly to acknowledge receipt
  • Use out_trade_no to match webhooks to your orders
  • Store invoice_reference and trx_ref for 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/status to query current status
  • Pass your out_trade_no to retrieve payment status