# Webhook (/vi/developers/webhooks)





Khi tài khoản ngân hàng có biến động, PayFS gửi một HTTP POST tới endpoint của bạn.

Chưa dựng endpoint bao giờ, đọc [Bắt đầu nhanh](/vi/developers/quickstart) trước.

## Payload [#payload]

```json
{
  "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"
}
```

| Trường                | Kiểu   | Mô tả                                                            |
| --------------------- | ------ | ---------------------------------------------------------------- |
| `transaction_id`      | string | ID giao dịch, duy nhất toàn hệ thống. Dùng làm khóa chống trùng. |
| `account_id`          | string | ID tài khoản ngân hàng trong PayFS                               |
| `amount`              | number | Số tiền, đơn vị VND, luôn là số nguyên dương                     |
| `transfer_type`       | string | `credit` là tiền vào, `debit` là tiền ra                         |
| `content`             | string | Nội dung chuyển khoản do ngân hàng trả về                        |
| `transaction_date`    | string | Thời điểm giao dịch, ISO 8601 theo giờ UTC                       |
| `bank_account_number` | string | Số tài khoản nhận hoặc gửi                                       |
| `bank`                | string | Mã ngân hàng: `MB`, `ACB`, `OCB`                                 |

<Callout type="info">
  `amount` luôn dương. Dùng `transfer_type` để biết tiền vào hay ra, đừng dựa vào dấu.
</Callout>

## Sự kiện [#sự-kiện]

Khi tạo webhook, bạn chọn sự kiện muốn nhận.

| Sự kiện              | Khi nào                        | Payload  |
| -------------------- | ------------------------------ | -------- |
| `transaction.credit` | Tiền vào tài khoản             | Như trên |
| `transaction.debit`  | Tiền ra khỏi tài khoản         | Như trên |
| `order.success`      | Đơn hàng thanh toán thành công | Xem dưới |
| `order.failed`       | Đơn hàng thanh toán thất bại   | Xem dưới |

Hai sự kiện `order.*` dùng cấu trúc khác:

```json
{
  "order_id": "1418108930751619072",
  "amount": 250000,
  "status": "success",
  "customer_email": "khachhang@example.com",
  "created_at": "2025-09-15T15:02:00.000Z"
}
```

Chỉ `order_id`, `amount` và `status` là bắt buộc. Ba trường còn lại có thể vắng.

<Callout type="info">
  `order.success` và `order.failed` là sự kiện webhook của Workspace. Chúng không đổi trong bản này. Callback của một Liên kết thanh toán là HTTPS POST riêng tới Callback URL của liên kết đó, không phải hai sự kiện này. Xem [Liên kết thanh toán](/vi/developers/payment-link).
</Callout>

## Header [#header]

PayFS gửi kèm 5 header sau mỗi lần gọi.

| Header               | Ví dụ         | Dùng để                                     |
| -------------------- | ------------- | ------------------------------------------- |
| `X-Client-API-Key`   | `pk_1234...`  | Xác thực nguồn gọi. &#x2A;*Luôn kiểm tra.** |
| `X-PayFS-Signature`  | `86f02cef...` | Chữ ký HMAC-SHA256, hex thô 64 ký tự        |
| `X-PayFS-Timestamp`  | `1758173916`  | UNIX timestamp, dùng chung với chữ ký       |
| `X-PayFS-Webhook-ID` | `1418108...`  | ID của webhook endpoint                     |
| `X-PayFS-Attempt`    | `1`           | Lần gửi thứ mấy                             |

Kiểm tra API key là bắt buộc. Kiểm tra chữ ký là tùy chọn nhưng nên làm, xem
[Chữ ký webhook](/vi/developers/webhook-signature).

## Xác thực bằng API key [#xác-thực-bằng-api-key]

```javascript
app.post('/payfs-webhook', (req, res) => {
  const apiKey = req.headers['x-client-api-key'];

  if (!apiKey || apiKey !== process.env.PAYFS_WEBHOOK_API_KEY) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

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

```php
<?php
$apiKey = $_SERVER['HTTP_X_CLIENT_API_KEY'] ?? null;

if (!$apiKey || !hash_equals($_ENV['PAYFS_WEBHOOK_API_KEY'], $apiKey)) {
    http_response_code(401);
    echo json_encode(['error' => 'Unauthorized']);
    exit;
}

$transaction = json_decode(file_get_contents('php://input'), true);
handleTransaction($transaction);

http_response_code(200);
echo json_encode(['status' => 'ok']);
```

## Chống xử lý trùng [#chống-xử-lý-trùng]

PayFS gửi lại khi thất bại, nên cùng một giao dịch có thể tới nhiều lần. Dùng
`transaction_id` làm khóa chống trùng.

```javascript
if (await daXuLy(tx.transaction_id)) {
  return res.status(200).json({ status: 'already_processed' });
}
await danhDauDaXuLy(tx.transaction_id);
```

Lưu vào cơ sở dữ liệu hoặc Redis, đừng lưu trong bộ nhớ tiến trình.

## Gửi lại khi thất bại [#gửi-lại-khi-thất-bại]

PayFS coi là thành công khi endpoint trả mã 2xx.

| Thông số                  | Mặc định |
| ------------------------- | -------- |
| Thời gian chờ mỗi lần gửi | 30 giây  |
| Số lần thử tối đa         | 10       |
| Khoảng chờ lần đầu        | 10 giây  |
| Hệ số tăng khoảng chờ     | 2        |

<Callout type="warn">
  Endpoint phải trả lời trong **30 giây**. Xử lý lâu thì trả 200 trước rồi đẩy việc
  sang hàng đợi nền. Trả lời chậm bị tính là thất bại và PayFS sẽ gửi lại.
</Callout>

## Mã trạng thái nên trả về [#mã-trạng-thái-nên-trả-về]

| Tình huống             | Mã                    |
| ---------------------- | --------------------- |
| Xử lý xong             | 200                   |
| Thiếu hoặc sai API key | 401                   |
| Payload không hợp lệ   | 400                   |
| Lỗi tạm thời phía bạn  | 500, PayFS sẽ gửi lại |

## Xử lý sự cố [#xử-lý-sự-cố]

**Thiếu header `X-Client-API-Key`**
Đa số framework chuẩn hóa tên header về chữ thường. Đọc `x-client-api-key`.

**Sai API key**
Đối chiếu lại với giá trị trong Client Portal, đồng thời kiểm tra chuỗi không bị cắt khi truyền.

**Webhook không được gọi**
Kiểm tra URL truy cập được từ Internet, đã chọn đúng sự kiện, và tường lửa không chặn.
Lịch sử gửi trong Client Portal cho biết PayFS đã thử gửi chưa và nhận được mã phản hồi nào.
