PayFS Documentation
Phát triển

Chữ ký webhook

Xác minh chữ ký HMAC-SHA256 để chắc chắn webhook đến từ PayFS.

Kiểm tra API key đã đủ để biết ai gọi. Kiểm tra chữ ký cho biết thêm rằng nội dung không bị sửa trên đường truyền, và chống tấn công phát lại.

Nên làm với mọi endpoint liên quan tới tiền.

Thuật toán

PayFS ký theo ba bước:

1. sorted = sắp xếp khóa JSON theo alphabet, đệ quy
2. data   = timestamp + "." + JSON.stringify(sorted)
3. sig    = HMAC_SHA256(data, webhook_secret) dạng hex

Ba điểm hay làm sai:

  1. Có dấu chấm giữa timestamp và payload. Thiếu nó thì chữ ký không bao giờ khớp.
  2. Khóa JSON phải được sắp xếp đúng như PayFS đã làm.
  3. Chữ ký là hex thô 64 ký tự, không có tiền tố. Đừng tìm sha256=, PayFS không gửi.

Chữ ký nằm ở header X-PayFS-Signature, timestamp ở X-PayFS-Timestamp. Chữ ký hết hạn sau 5 phút.

Node.js

const crypto = require('crypto');

function sortObjectKeys(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
  return Object.keys(obj).sort().reduce((acc, k) => {
    acc[k] = sortObjectKeys(obj[k]);
    return acc;
  }, {});
}

function verifyWebhookSignature(payload, signature, timestamp, secret) {
  // 1. Chặn tấn công phát lại
  if (Math.floor(Date.now() / 1000) - timestamp > 300) {
    return { valid: false, error: 'Chữ ký đã hết hạn' };
  }

  // 2. Dựng lại chuỗi được ký
  const sorted = JSON.stringify(sortObjectKeys(JSON.parse(payload)));
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${sorted}`, 'utf8')
    .digest('hex');

  // 3. So sánh theo thời gian hằng số.
  //    Kiểm tra độ dài trước, vì timingSafeEqual ném lỗi khi hai buffer lệch độ dài.
  if (signature.length !== expected.length) {
    return { valid: false, error: 'Chữ ký không hợp lệ' };
  }

  const ok = crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );

  return ok ? { valid: true } : { valid: false, error: 'Chữ ký không hợp lệ' };
}

Dùng trong endpoint:

app.post('/payfs-webhook', (req, res) => {
  const apiKey = req.headers['x-client-api-key'];
  if (apiKey !== process.env.PAYFS_WEBHOOK_API_KEY) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  const result = verifyWebhookSignature(
    JSON.stringify(req.body),
    req.headers['x-payfs-signature'],
    parseInt(req.headers['x-payfs-timestamp'], 10),
    process.env.PAYFS_WEBHOOK_SECRET
  );

  if (!result.valid) {
    return res.status(401).json({ error: result.error });
  }

  handleTransaction(req.body);
  res.status(200).json({ status: 'ok' });
});

PHP

<?php
function sortObjectKeys($obj) {
    if (!is_array($obj)) return $obj;
    if (array_keys($obj) === range(0, count($obj) - 1)) {
        return array_map('sortObjectKeys', $obj);
    }
    ksort($obj);
    foreach ($obj as $k => $v) $obj[$k] = sortObjectKeys($v);
    return $obj;
}

function verifyWebhookSignature($payload, $signature, $timestamp, $secret) {
    if (time() - $timestamp > 300) {
        return false;
    }

    $sorted = json_encode(sortObjectKeys(json_decode($payload, true)), JSON_UNESCAPED_SLASHES);
    $expected = hash_hmac('sha256', $timestamp . '.' . $sorted, $secret);

    return hash_equals($expected, $signature);
}

Kiểm thử

Ví dụ dưới đây tái lập được. Chạy thử để chắc chắn code của bạn đúng.

const crypto = require('crypto');

const secret = 'whsec_example_secret_do_not_use_in_production';
const timestamp = 1758173916;
const payload = '{"account_id":"1418079746853494784","amount":14000,"bank":"MB","bank_account_number":"0933723830","content":"NGUYEN VAN A chuyen tien  Ma giao dich  Trace773231","transaction_date":"2025-09-15T15:02:00.000Z","transaction_id":"1418108930751619072","transfer_type":"credit"}';

const sig = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${payload}`, 'utf8')
  .digest('hex');

console.log(sig);
// 86f02cefae56d51f72a00e04bf9d5a96b40cfe6902d54e495234c447ec706c5e

Không ra đúng chuỗi trên nghĩa là bạn đã sai ở một trong ba điểm nêu đầu trang.

Xử lý sự cố

Chữ ký không hợp lệ

  1. Thiếu dấu chấm giữa timestamp và payload
  2. Khóa JSON chưa được sắp xếp
  3. Code đang cắt tiền tố sha256= mà PayFS không gửi
  4. Sai webhook secret
  5. Payload bị sửa trước khi xác minh, hãy dùng body thô

Chữ ký đã hết hạn

Đồng hồ máy chủ lệch, hoặc endpoint xử lý quá 5 phút. Đồng bộ giờ bằng NTP, và trả 200 trước rồi xử lý nghiệp vụ sau.

On this page