Signature Example & Callback Verification
This page provides working examples to help you understand how to sign requests and verify callback signatures.
Important: You must use the raw HTTP body to verify signatures. Using
json_encode()on the parsed data will produce a different signature due to JSON formatting differences.
Test Credentials
For testing purposes, use:
| Parameter | Value |
|---|---|
| merchant_id | TH00000000 |
| secret_key | aaaaaaaaaaaaaaaaaaaa |
Part 1: Simulate Callback (Sender)
These scripts simulate the payment gateway sending a callback to your server.
Node.js (Sender)
Save as simulate-callback.js:
const crypto = require('crypto');
const http = require('http');
// Test credentials
const MERCHANT_ID = 'TH00000000';
const SECRET_KEY = 'aaaaaaaaaaaaaaaaaaaa';
// Callback URL (your server)
const CALLBACK_HOST = 'localhost';
const CALLBACK_PORT = 3000;
const CALLBACK_PATH = '/callback';
// Simulate callback data
const callbackData = {
merchant_id: MERCHANT_ID,
platform_order_id: 'THBP2024042821130000000001',
client_order_id: 'ORDER123456789',
mode: 'PAYMENT',
amount: '1000.00',
status: 'PAID',
timestamp: Math.floor(Date.now() / 1000)
};
// Convert to JSON string (this is the raw body)
const rawBody = JSON.stringify(callbackData);
// Calculate signature using raw body
const signature = crypto
.createHmac('sha256', SECRET_KEY)
.update(rawBody)
.digest('hex');
console.log('=== Simulating Callback ===');
console.log('Raw Body:', rawBody);
console.log('Signature:', signature);
console.log('');
// Send HTTP request
const options = {
hostname: CALLBACK_HOST,
port: CALLBACK_PORT,
path: CALLBACK_PATH,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
console.log('Response Status:', res.statusCode);
console.log('Response Body:', data);
});
});
req.on('error', (e) => {
console.error('Error:', e.message);
console.log('Make sure your callback server is running on port', CALLBACK_PORT);
});
req.write(rawBody);
req.end();
PHP (Sender)
Save as simulate-callback.php:
<?php
// Test credentials
$merchant_id = 'TH00000000';
$secret_key = 'aaaaaaaaaaaaaaaaaaaa';
// Callback URL (your server)
$callback_url = 'http://localhost:8080/callback.php';
// Simulate callback data
$callback_data = [
'merchant_id' => $merchant_id,
'platform_order_id' => 'THBP2024042821130000000001',
'client_order_id' => 'ORDER123456789',
'mode' => 'PAYMENT',
'amount' => '1000.00',
'status' => 'PAID',
'timestamp' => time()
];
// Convert to JSON string (this is the raw body)
$raw_body = json_encode($callback_data);
// Calculate signature using raw body
$signature = hash_hmac('SHA256', $raw_body, $secret_key);
echo "=== Simulating Callback ===\n";
echo "Raw Body: " . $raw_body . "\n";
echo "Signature: " . $signature . "\n\n";
// Send HTTP request
$ch = curl_init($callback_url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw_body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Signature: ' . $signature
]
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
echo "Error: " . $error . "\n";
echo "Make sure your callback server is running\n";
} else {
echo "Response Status: " . $http_code . "\n";
echo "Response Body: " . $response . "\n";
}
Run: php simulate-callback.php
Part 2: Receive & Verify Callback (Server)
Node.js + Express (Server)
Save as callback-server.js:
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = 3000;
const SECRET_KEY = 'aaaaaaaaaaaaaaaaaaaa';
// IMPORTANT: We need the raw body for signature verification
// Store raw body before JSON parsing
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
app.post('/callback', (req, res) => {
console.log('\n=== Callback Received ===');
const receivedSignature = req.headers['x-signature'];
const rawBody = req.rawBody;
const parsedData = req.body;
console.log('Received Signature:', receivedSignature);
console.log('Raw Body:', rawBody);
console.log('Parsed Data:', JSON.stringify(parsedData, null, 2));
// =====================================================
// CORRECT WAY: Use raw body to calculate signature
// =====================================================
const correctSignature = crypto
.createHmac('sha256', SECRET_KEY)
.update(rawBody) // Use raw body!
.digest('hex');
console.log('\n--- CORRECT Verification (using raw body) ---');
console.log('Calculated Signature:', correctSignature);
console.log('Match:', correctSignature === receivedSignature ? 'YES' : 'NO');
// =====================================================
// WRONG WAY: Re-encode parsed JSON
// This may produce different JSON formatting!
// =====================================================
const reEncodedBody = JSON.stringify(parsedData);
const wrongSignature = crypto
.createHmac('sha256', SECRET_KEY)
.update(reEncodedBody) // WRONG: Re-encoded body
.digest('hex');
console.log('\n--- WRONG Verification (re-encoding JSON) ---');
console.log('Re-encoded Body:', reEncodedBody);
console.log('Calculated Signature:', wrongSignature);
console.log('Match:', wrongSignature === receivedSignature ? 'YES' : 'NO');
// In this example they might match, but consider:
// - Different JSON key ordering
// - Different whitespace/formatting
// - Unicode escaping differences
// - Number formatting (1000 vs 1000.00)
// Verify signature (correct way)
if (correctSignature === receivedSignature) {
console.log('\n*** SIGNATURE VALID ***');
// Process the callback
console.log('Processing payment:', parsedData.client_order_id);
console.log('Amount:', parsedData.amount);
console.log('Status:', parsedData.status);
res.status(200).json({ success: true, message: 'Callback processed' });
} else {
console.log('\n*** SIGNATURE INVALID ***');
res.status(403).json({ error: 'Invalid signature' });
}
});
app.listen(PORT, () => {
console.log(`Callback server running on http://localhost:${PORT}`);
console.log('Waiting for callbacks on POST /callback');
});
Install & Run:
npm init -y
npm install express
node callback-server.js
PHP (Server)
Save as callback.php:
<?php
header('Content-Type: application/json');
$secret_key = 'aaaaaaaaaaaaaaaaaaaa';
echo "=== Callback Received ===\n";
// Get received signature from header
$received_signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// =====================================================
// IMPORTANT: Get the RAW body before any processing
// =====================================================
$raw_body = file_get_contents('php://input');
// Parse JSON for processing
$parsed_data = json_decode($raw_body, true);
echo "Received Signature: " . $received_signature . "\n";
echo "Raw Body: " . $raw_body . "\n";
echo "Parsed Data: " . print_r($parsed_data, true) . "\n";
// =====================================================
// CORRECT WAY: Use raw body to calculate signature
// =====================================================
$correct_signature = hash_hmac('SHA256', $raw_body, $secret_key);
echo "\n--- CORRECT Verification (using raw body) ---\n";
echo "Calculated Signature: " . $correct_signature . "\n";
echo "Match: " . ($correct_signature === $received_signature ? 'YES' : 'NO') . "\n";
// =====================================================
// WRONG WAY: Re-encode parsed JSON
// This may produce different JSON formatting!
// =====================================================
$re_encoded_body = json_encode($parsed_data);
$wrong_signature = hash_hmac('SHA256', $re_encoded_body, $secret_key);
echo "\n--- WRONG Verification (re-encoding JSON) ---\n";
echo "Re-encoded Body: " . $re_encoded_body . "\n";
echo "Calculated Signature: " . $wrong_signature . "\n";
echo "Match: " . ($wrong_signature === $received_signature ? 'YES' : 'NO') . "\n";
// Verify signature (correct way)
if (hash_equals($correct_signature, $received_signature)) {
echo "\n*** SIGNATURE VALID ***\n";
// Process the callback
echo "Processing payment: " . $parsed_data['client_order_id'] . "\n";
echo "Amount: " . $parsed_data['amount'] . "\n";
echo "Status: " . $parsed_data['status'] . "\n";
http_response_code(200);
echo json_encode(['success' => true, 'message' => 'Callback processed']);
} else {
echo "\n*** SIGNATURE INVALID ***\n";
http_response_code(403);
echo json_encode(['error' => 'Invalid signature']);
}
Run with PHP built-in server:
php -S localhost:8080
Part 3: Demo - Why Raw Body Matters
This example shows exactly why re-encoding JSON can break signature verification:
Node.js Demo
Save as demo-difference.js:
const crypto = require('crypto');
const SECRET_KEY = 'aaaaaaaaaaaaaaaaaaaa';
// Original JSON from sender (with specific formatting)
const originalJson = '{"merchant_id":"TH00000000","amount":"1000.00","status":"PAID"}';
// Parse and re-encode
const parsed = JSON.parse(originalJson);
const reEncoded = JSON.stringify(parsed);
// Add some whitespace (common in pretty-printed JSON)
const prettyJson = JSON.stringify(parsed, null, 2);
console.log('=== JSON Comparison ===');
console.log('Original :', originalJson);
console.log('Re-encoded:', reEncoded);
console.log('Pretty :', prettyJson.replace(/\n/g, '\\n'));
console.log('');
// Calculate signatures
const sigOriginal = crypto.createHmac('sha256', SECRET_KEY).update(originalJson).digest('hex');
const sigReEncoded = crypto.createHmac('sha256', SECRET_KEY).update(reEncoded).digest('hex');
const sigPretty = crypto.createHmac('sha256', SECRET_KEY).update(prettyJson).digest('hex');
console.log('=== Signatures ===');
console.log('Original :', sigOriginal);
console.log('Re-encoded:', sigReEncoded);
console.log('Pretty :', sigPretty);
console.log('');
console.log('Original === Re-encoded:', sigOriginal === sigReEncoded);
console.log('Original === Pretty :', sigOriginal === sigPretty);
// Example with different key ordering
const differentOrder = '{"amount":"1000.00","merchant_id":"TH00000000","status":"PAID"}';
const sigDifferentOrder = crypto.createHmac('sha256', SECRET_KEY).update(differentOrder).digest('hex');
console.log('');
console.log('=== Key Order Matters ===');
console.log('Original :', originalJson);
console.log('Different Order:', differentOrder);
console.log('Signatures match:', sigOriginal === sigDifferentOrder);
Run: node demo-difference.js
Expected Output:
=== JSON Comparison ===
Original : {"merchant_id":"TH00000000","amount":"1000.00","status":"PAID"}
Re-encoded: {"merchant_id":"TH00000000","amount":"1000.00","status":"PAID"}
Pretty : {\n "merchant_id": "TH00000000",\n "amount": "1000.00",\n "status": "PAID"\n}
=== Signatures ===
Original : abc123...
Re-encoded: abc123...
Pretty : xyz789... <- DIFFERENT!
Original === Re-encoded: true
Original === Pretty : false
=== Key Order Matters ===
Original : {"merchant_id":"TH00000000","amount":"1000.00","status":"PAID"}
Different Order: {"amount":"1000.00","merchant_id":"TH00000000","status":"PAID"}
Signatures match: false <- DIFFERENT KEY ORDER = DIFFERENT SIGNATURE!
Summary
| Approach | Result |
|---|---|
Use file_get_contents('php://input') or req.rawBody | CORRECT |
Use json_encode($parsed_data) or JSON.stringify(parsed) | WRONG (may work by coincidence) |
Key Points
- Always save the raw HTTP body before parsing JSON
- Never re-encode parsed JSON for signature verification
- JSON is not canonical - same data can have different string representations:
- Key ordering may differ
- Whitespace/formatting may differ
- Unicode escaping may differ
- Number formatting may differ (1000 vs 1000.0)
Quick Reference
PHP:
$raw_body = file_get_contents('php://input');
$signature = hash_hmac('SHA256', $raw_body, $secret_key);
Node.js (Express):
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
// In route handler:
const signature = crypto.createHmac('sha256', SECRET_KEY)
.update(req.rawBody)
.digest('hex');