# Chữ ký webhook (/vi/developers/webhook-signature)





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 [#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:

<Callout type="warn">
  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.
</Callout>

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

## Node.js [#nodejs]

```javascript
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:

```javascript
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]

```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ử [#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.

```javascript
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ố [#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.
