Hướng dẫn xác thực Webhook Callback (Authentication Guide)

Tài liệu tích hợp dành cho Partner.

Về trang chủ Partner API Documentation

1. HTTP Headers

Mỗi callback POST gửi đến Partner đều kèm theo các header sau:

Header Kiểu Mô tả
timestamp string Unix Epoch Milliseconds (UTC) tại thời điểm gửi.
nonce string Chuỗi ngẫu nhiên 16 ký tự, duy nhất cho mỗi request. Dùng để chống replay attack thay cho time window.
signature string HMAC-SHA256 của signingString, encode Hex lowercase (64 ký tự).

2. Cách tính Signature

2.1 Công thức

Ghép 3 giá trị lại với nhau, dùng ký tự | làm separator:

signingString = clientId + "|" + apiKey + "|" + timestamp + "|" + nonce

Sau đó tính HMAC-SHA256 với secretKey làm key mã hoá, encode kết quả sang Hex lowercase:

signature = HMAC-SHA256(key = secretKey, message = signingString)   // hex lowercase
⚠️ Lưu ý: Body KHÔNG tham gia vào signingString. Partner không cần đọc hay xử lý body để verify.

2.2 Thành phần

Thành phần Lấy từ Ví dụ
clientId Do hệ thống cấp riêng cho từng Partner partner_abc123
timestamp Header timestamp 1750000000000
nonce Header nonce a3f9b2c1d4e5f607
apiKey Do hệ thống cấp riêng cho từng Partner sk_live_xxxxx
secretKey Do hệ thống cấp riêng cho từng Partner (dùng để mã hoá) secret_live_xxxxx

2.3 Ví dụ cụ thể

Giả sử Partner nhận được POST request với:

timestamp: 1750000000000
nonce:     a3f9b2c1d4e5f607
signature: 7f4a2c9e1d3b...
Body:        {...}   // bất kỳ — không dùng để verify

Partner tự tính lại signature như sau:

// Bước 1: Tạo signingString
signingString = "partner_abc123|sk_live_xxxxxxxxxxxxxxxxxxxx|1750000000000|a3f9b2c1d4e5f607"

// Bước 2: Tính HMAC-SHA256
secretKey = "sk_live_xxxxxxxxxxxxxxxxxxxx"   // lấy từ config
signature = HMAC-SHA256(secretKey, signingString)
          = "7f4a2c9e1d3b..."                // 64 ký tự hex

// Bước 3: So sánh với X-Signature nhận được
"7f4a2c9e1d3b..." === "7f4a2c9e1d3b..."  →  HỢP LỆ → trả 200

3. Xác thực phía Partner

Thực hiện đúng thứ tự 3 bước. Fail bất kỳ bước nào → trả 401 ngay, không xử lý tiếp.

# Kiểm tra Điều kiện fail → 401
1 Timestamp |now_ms − X-Timestamp| > 300,000 ms (5 phút)
2 Nonce nonce đã thấy trong 10 phút vừa qua (lưu cache/DB theo clientId)
3 Signature Tính lại signature ≠ signature (so sánh constant-time)
💡 Nonce cache: lưu key = "clientId:nonce", TTL = 10 phút. Dùng Redis SET NX EX hoặc DB unique constraint. Nếu SET NX trả false (key đã tồn tại) → nonce đã dùng → 401.

4. Code mẫu theo ngôn ngữ

4.1 Node.js

const crypto = require('crypto');

const CREDENTIALS_STORE = {
    MYF88APP: {
        clientId: 'MYF88APP',
        apiKey: '72bf2963-f1e4-4742-9089-3f08ed860325',
        secretKey: 'c6ba90ed558f0c1b28b3d69529bcf886f5b56c85cf512f4e9aef1b65b7336ba6',
    },
};

async function verifyWebhook(req) {
    const timestamp = req.headers['timestamp'];
    const nonce = req.headers['nonce'];
    const signature = req.headers['signature'];

    if (!timestamp || !nonce || !signature) {
        console.log('[verifyWebhook] missing field');
        return false;
    }

    // Timestamp anti-replay (±5 phút)
    if (Math.abs(Date.now() - Number(timestamp)) > 300_000) {
        console.log('[verifyWebhook] timestamp out of range');
        return false;
    }

    // Lookup credentials theo clientId
    const credentials = CREDENTIALS_STORE['MYF88APP'];
    if (!credentials) {
        console.log('[verifyWebhook] unknown clientId');
        return false;
    }

    // Verify signature
    const signingString = `${credentials.clientId}|${credentials.apiKey}|${timestamp}|${nonce}`;
    const expected = crypto.createHmac('sha256', credentials.secretKey)
        .update(signingString, 'utf8')
        .digest('hex');

    const expectedBuf = Buffer.from(expected, 'hex');
    const signatureBuf = Buffer.from(signature, 'hex');

    if (expectedBuf.length !== signatureBuf.length) {
        console.log('[verifyWebhook] signature length mismatch');
        return false;
    }

    const valid = crypto.timingSafeEqual(expectedBuf, signatureBuf);
    if (!valid) {
        console.log('[verifyWebhook] signature mismatch, expected:', expected);
        return false;
    }

    return true;
}

function generateWebhookAuth(credentials) {
  if (!credentials || !credentials.clientId || !credentials.apiKey || !credentials.secretKey) {
    return {};
  }

  const timestamp = Date.now().toString();
  // Tạo nonce ngẫu nhiên 16 ký tự hex
  const nonce = crypto.randomBytes(8).toString('hex');

  // Tạo chuỗi ký
  const signingString = `${credentials.clientId}|${credentials.apiKey}|${timestamp}|${nonce}`;

  // Tính HMAC-SHA256 và chuyển sang dạng hex lowercase
  const signature = crypto.createHmac('sha256', credentials.secretKey)
    .update(signingString, 'utf8')
    .digest('hex');

  return {
    timestamp,
    nonce,
    signature
  };
  
}

5. HTTP Response

Partner phải trả về đúng HTTP status code theo bảng sau:

Status Khi nào Hành vi hệ thống
200 OK Xác thực OK, xử lý thành công Ghi nhận. Không retry.
401 Xác thực thất bại Ghi log. Retry có giới hạn.
500 Lỗi nội bộ phía Partner Retry exponential backoff.

6. Lỗi thường gặp & cách fix

Triệu chứng Nguyên nhân Fix
Signature luôn sai dù key đúng Dùng sai separator hoặc sai thứ tự Đảm bảo đúng: clientId|apiKey|timestamp|nonce (ký tự | ASCII 0x7C)
Signature đúng nhưng vẫn 401 Timestamp lệch > 5 phút Sync NTP, đảm bảo server time chính xác
Nonce bị reject liên tục TTL cache quá ngắn hoặc nonce bị trùng khi retry Tăng TTL lên 10 phút; hệ thống không retry với cùng nonce
Signature sai khi có ký tự đặc biệt Encoding không nhất quán Dùng UTF-8 cho toàn bộ chuỗi khi tính HMAC

7. Lưu ý bảo mật