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)
amountnumberActual amount received/credited (may differ from requested amount)
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)
original_amountnumberOriginally requested amount (only present if amount mismatch occurred)
payment_match_statusstringPayment match status: exact, overpaid, or underpaid

Amount Mismatch Handling

Since users manually enter the payment amount (free-form USD input), the actual amount paid may differ from the requested amount. When this happens:

  • The actual paid amount is credited to your wallet (not the requested amount)
  • The webhook includes original_amount showing what was originally requested
  • The webhook includes payment_match_status indicating the mismatch type

Example webhook with amount mismatch:

json
{
  "success": true,
  "code": 200,
  "data": {
    "trx_ref": "TX-ABC123",
    "amount": 150.00,
    "original_amount": 100.00,
    "payment_match_status": "overpaid",
    "currency": "USDT",
    "fee": 1.5,
    "invoice_reference": "PAYIN-ABCD123456",
    "out_trade_no": "MERCHANT-ORDER-001",
    "status": "success"
  }
}
payment_match_statusDescription
exactUser paid exactly the requested amount
overpaidUser paid more than requested (e.g., requested 100, paid 150)
underpaidUser paid less than requested (e.g., requested 100, paid 80)

Handling Amount Mismatches

Always use the amount field to determine how much was actually credited to your wallet. If reconciliation is needed, compare amount with original_amount to calculate the difference.

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