# Xác minh chữ ký webhook

Thông báo thanh toán đến một địa chỉ chỉ cửa hàng của bạn và dịch vụ của chúng tôi biết. Nhưng địa chỉ có thể rò rỉ — từ log, từ cấu hình, từ lịch sử phát triển. Để phân biệt thông báo thật với giả mạo, mỗi yêu cầu được ký bằng khóa bí mật webhook.

Hãy xác minh chữ ký trước khi giao hàng hoặc đổi trạng thái đơn hàng.

## Các header chữ ký

| Header        | Giá trị                                            |
| ------------- | ------------------------------------------------ |
| `X-Timestamp` | Thời điểm gửi, unix time tính bằng giây                  |
| `X-Signature` | Tiền tố `sha256=` và HMAC-SHA256 ở dạng hex  |

Chuỗi được ký gồm thời điểm gửi, một dấu chấm và phần thân yêu cầu:

```
1756901234.{"wallet":{...},"project":{...},"invoice":{...},"payment":{...}}
```

Khóa ký là trường Khóa bí mật webhook (Webhook secret) trong cài đặt dự án. Bạn có thể cấp lại nó ở đó nếu giá trị bị lộ.

## Các bước xác minh

1. Lấy phần thân yêu cầu thô, trước khi phân tích JSON.
2. Nối giá trị `X-Timestamp`, một dấu chấm và phần thân.
3. Tính HMAC-SHA256 với khóa bí mật webhook.
4. So sánh kết quả với `X-Signature` bằng phép so sánh thời gian không đổi.
5. Từ chối yêu cầu nếu `X-Timestamp` lệch quá xa so với thời gian hiện tại.

## Ví dụ triển khai

**PHP**

```php showLineNumbers
<?php
$secret = 'webhook secret from your project settings';

$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

// Guards against a replayed request
if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(400);
    exit;
}

$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);

// hash_equals compares in constant time
if (!hash_equals($expected, $signature)) {
    http_response_code(403);
    exit;
}

$data = json_decode($body, true);

// Handle the order

http_response_code(200);
```

**TypeScript**

```typescript showLineNumbers
import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { type Request, type Response } from 'express';

const app = express();
const secret = 'webhook secret from your project settings';

// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
    const timestamp = req.get('X-Timestamp') ?? '';
    const signature = req.get('X-Signature') ?? '';
    const body = (req.body as Buffer).toString('utf8');

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
        return res.sendStatus(400);
    }

    const expected = 'sha256=' + createHmac('sha256', secret)
        .update(`${timestamp}.${body}`, 'utf8')
        .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);

    // Check the length first: timingSafeEqual needs buffers of equal size
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return res.sendStatus(403);
    }

    const data = JSON.parse(body);

    // Handle the order

    res.sendStatus(200);
});
```

**JavaScript**

Cùng trình xử lý đó nhưng không có kiểu — cho dự án JavaScript thuần.

```js showLineNumbers
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
const secret = 'webhook secret from your project settings';

// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.get('X-Timestamp') ?? '';
    const signature = req.get('X-Signature') ?? '';
    const body = req.body.toString('utf8');

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
        return res.sendStatus(400);
    }

    const expected = 'sha256=' + createHmac('sha256', secret)
        .update(`${timestamp}.${body}`, 'utf8')
        .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);

    // Check the length first: timingSafeEqual needs buffers of equal size
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return res.sendStatus(403);
    }

    const data = JSON.parse(body);

    // Handle the order

    res.sendStatus(200);
});
```

**Python**

```python showLineNumbers
import hmac
import hashlib
import time

from flask import Flask, request

app = Flask(__name__)
secret = 'webhook secret from your project settings'

@app.post('/webhook')
def webhook():
    timestamp = request.headers.get('X-Timestamp', '')
    signature = request.headers.get('X-Signature', '')
    body = request.get_data(as_text=True)

    if abs(time.time() - int(timestamp or 0)) > 300:
        return '', 400

    expected = 'sha256=' + hmac.new(
        secret.encode(),
        f'{timestamp}.{body}'.encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return '', 403

    data = request.get_json()

    # Handle the order

    return '', 200
```

## Những điều cần nhớ

**Chữ ký tính trên phần thân thô.** Nếu bạn phân tích JSON rồi dựng lại, thứ tự khóa và khoảng trắng thay đổi — chữ ký sẽ không khớp. Hãy tính HMAC trước khi phân tích.

**Mỗi lần gửi lại có chữ ký riêng.** Thời điểm gửi mới ở mỗi lần thử lại, nên chữ ký cũng khác. Đừng lưu chữ ký để so với các yêu cầu sau.

**Khóa bí mật có thể được cấp lại.** Khóa cũ ngừng hoạt động ngay sau khi cấp lại, vì vậy hãy cập nhật giá trị trong cửa hàng của bạn cùng lúc.

**Xác minh chữ ký không thay thế tính idempotent.** Chữ ký chứng minh yêu cầu là thật, chứ không phải bạn thấy nó lần đầu. Lần gửi lại đến với chữ ký hợp lệ — hãy đối chiếu theo `invoice.id`.
