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:

ParameterValue
merchant_idTH00000000
secret_keyaaaaaaaaaaaaaaaaaaaa

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

ApproachResult
Use file_get_contents('php://input') or req.rawBodyCORRECT
Use json_encode($parsed_data) or JSON.stringify(parsed)WRONG (may work by coincidence)

Key Points

  1. Always save the raw HTTP body before parsing JSON
  2. Never re-encode parsed JSON for signature verification
  3. 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');