Webhook sự kiện Hosted Link

Nội dung Markdown đầy đủ của trang tài liệu.

# Webhook sự kiện Hosted Link

Webhook này thông báo tiến trình [Hosted Link](./hosted-link.md) và trạng thái tài khoản. Webhook giao dịch tiền vào/ra có [cấu hình và giao thức riêng](./webhook.md).

## Cấu hình nơi nhận

Trong Merchant → BankHub → Thiết lập → **Webhook event — trạng thái liên kết**, lưu URL HTTPS và token webhook. Token gồm 32–128 ký tự chữ, số, `_`, `-`; đây là token webhook, không phải API token. Endpoint phải công khai, HTTPS cổng 443; không chuyển hướng sang URL khác.

Một URL có thể nhận cả hai nhóm bên dưới. Phân loại theo `event`; payload hiện tại không có trường `category` và không có tùy chọn đăng ký từng nhóm. Return URL chỉ đưa trình duyệt quay lại, không nhận POST webhook.

## Hai nhóm sự kiện

### Tiến trình phiên

| Event | Ý nghĩa |
| --- | --- |
| `LINK_TOKEN_CREATED` | Đã tạo phiên/link |
| `LINK_SESSION_INITIALIZED` | Khách mở phiên lần đầu |
| `LINK_SESSION_STARTED` | Khách đồng ý và bắt đầu |
| `LINK_SESSION_OTP_REQUIRED` | Chờ OTP |
| `LINK_SESSION_VERIFYING` | Đang xác minh OTP |
| `LINK_SESSION_COMPLETED` | Hoàn tất phiên; đọc `purpose` và `unlink_mode` |
| `LINK_SESSION_CANCELLED` | Khách hủy phiên |
| `LINK_SESSION_FAILED` | Phiên thất bại |
| `LINK_SESSION_EXPIRED` | Phiên hết hạn |
| `LINK_SESSION_REVOKED` | Phiên bị thu hồi |
| `LINK_SESSION_UNCERTAIN` | Chưa xác định kết quả, cần đối soát |
| `LINK_SESSION_STATE_CHANGED` | Thông báo trạng thái tổng quát được giữ để tương thích |

### Kết quả và trạng thái tài khoản

| Event | Ý nghĩa |
| --- | --- |
| `BANK_ACCOUNT_LINKED` | Liên kết thành công |
| `BANK_ACCOUNT_UNLINKED` | Đã xác nhận hủy phía ngân hàng; UAT là giả lập |
| `BANK_ACCOUNT_INACTIVED` | Vô hiệu hóa tại Pay2S, không xác nhận hủy phía ngân hàng |
| `BANK_ACCOUNT_REACTIVATED` | Bật lại tại Pay2S |
| `BANK_ACCOUNT_ENABLED` | Bộ đồng bộ quan sát tài khoản hoạt động trở lại |
| `BANK_ACCOUNT_DISABLED` | Bộ đồng bộ quan sát tài khoản bị vô hiệu hóa |
| `BANK_ACCOUNT_DISCONNECTED` | Bộ đồng bộ quan sát tài khoản bị xóa/ẩn |
| `BANK_ACCOUNT_STATE_SYNCED` | Đồng bộ trạng thái ban đầu của tài khoản cũ |

Thông báo quan sát trạng thái có `account_status`, `previous_account_status`, `bank_unlink_confirmed`; không suy ra đã hủy phía ngân hàng khi `bank_unlink_confirmed=false`. Thay đổi từ màn hình Pay2S bên ngoài Hosted Link được phát hiện qua đối soát định kỳ, có thể trễ hơn event phát trực tiếp từ phiên.

::: warning Hành vi hiện tại: nhiều event cho cùng kết quả
Khi liên kết thành công, hệ thống hiện gửi cả `LINK_SESSION_STATE_CHANGED`, `BANK_ACCOUNT_LINKED` và `LINK_SESSION_COMPLETED` với các `event_id` khác nhau. Khi chờ OTP/xác minh cũng có event cụ thể và event tổng quát. Đây không phải retry.

Đối tác nên cập nhật kết quả liên kết theo `BANK_ACCOUNT_LINKED`; dùng nhóm phiên để cập nhật tiến trình, không chạy lại nghiệp vụ liên kết cho cả ba thông báo. Việc rút gọn phía gửi thành một event cho mỗi thay đổi chưa được áp dụng trong phiên bản này.
:::

## Payload mẫu

```json
{
  "event_id": "0123456789abcdef0123456789abcdef",
  "version": "1.0",
  "timestamp": 1791691200,
  "environment": "uat",
  "event": "BANK_ACCOUNT_LINKED",
  "metadata": {
    "session_id": "SESSION_ID",
    "application_id": 2,
    "customer_reference": "CUSTOMER_DEMO",
    "bank_code": "ACB",
    "status": "connected",
    "purpose": "LINK_BANK_ACCOUNT",
    "state": "FINISHED_BANK_ACCOUNT_LINK",
    "unlink_mode": "bank",
    "account": {
      "id": 1,
      "bank_code": "ACB",
      "account_number": "P2S99999999",
      "user_bank_id": 1,
      "simulated": 1
    },
    "bank_profile": "acb_personal",
    "account_type": "personal",
    "connection_type": "openapi"
  },
  "sequence": 1
}
```

`event_id` xác định một event và giữ nguyên khi retry. `sequence` là thứ tự phát sinh, có thể có khoảng trống; không đảm bảo thứ tự nhận. `timestamp` trong body là lúc tạo event, không đổi khi retry. Giữ số tài khoản dưới dạng chuỗi để bảo toàn số 0 đầu.

Các phiên dùng loại ngân hàng mới có thêm `metadata.bank_profile`, `metadata.account_type` (`personal`, `business`, `household`) và `metadata.connection_type` (`openapi`, `rpa`). Những trường này bổ sung thông tin, không chứa username, mật khẩu hoặc OTP. Các phiên ACB cá nhân cũ có thể không có các trường bổ sung.

## Xác thực webhook

```http
Content-Type: application/json
Authorization: Bearer WEBHOOK_TOKEN
X-Pay2S-Event-Id: EVENT_ID
X-Pay2S-Timestamp: DELIVERY_UNIX_TIMESTAMP
X-Pay2S-Signature: v1=HEX_HMAC_SHA256
```

Chữ ký là HMAC-SHA256 với khóa bằng token webhook và chuỗi đầu vào:

```text
X-Pay2S-Timestamp + "." + X-Pay2S-Event-Id + "." + raw_request_body
```

1. Kiểm tra Bearer token bằng phép so sánh an toàn.
2. Kiểm tra timestamp header; có thể dùng cửa sổ 5 phút nếu đồng hồ server đồng bộ. Mỗi lần gửi có timestamp và chữ ký mới, không kiểm tra độ mới bằng timestamp trong body.
3. Tính HMAC trên body nguyên bản, không parse rồi JSON-encode lại; so sánh chữ ký an toàn.
4. Parse JSON, đối chiếu event ID header với body, `environment`, `metadata.application_id` và khách/phiên thuộc hệ thống của bạn.
5. Lưu event bền vững với khóa duy nhất `(environment, application_id, event_id)`. Chỉ trả `2xx` khi lưu thành công; xử lý nghiệp vụ trong hàng đợi riêng.

Ví dụ công thức PHP (các biến lấy từ header/body nguyên bản):

```php
$expected = 'v1=' . hash_hmac(
    'sha256',
    $timestampHeader . '.' . $eventIdHeader . '.' . $rawBody,
    $webhookToken
);
$validSignature = hash_equals($expected, $signatureHeader);
```

Đoạn này chỉ minh họa chữ ký, cần thực hiện đầy đủ các bước kiểm tra và lưu bền vững ở trên. Không ghi token, OTP hoặc toàn bộ dữ liệu tài khoản vào log.

## Chống trùng và thứ tự

- Cùng `event_id`: cùng event được gửi lại; nếu đã lưu thành công, trả `2xx` và không chạy nghiệp vụ lần hai.
- Khác `event_id` nhưng cùng phiên/trạng thái: có thể là các thông báo tương thích cùng mô tả một kết quả. Lọc loại event dùng cho nghiệp vụ như hướng dẫn trên.
- Lưu `sequence` mới nhất theo phiên hoặc theo tài khoản trong cùng ứng dụng/môi trường. Event cũ vẫn có thể lưu lịch sử nhưng không ghi đè trạng thái mới.
- Không so sánh sequence giữa production và UAT. Dùng API tra kết quả phiên để đối soát khi cần.

## Khi nào retry và khi nào dừng?

| Kết quả gửi | Xử lý |
| --- | --- |
| HTTP `2xx`, không lỗi transport | `delivered`, dừng retry |
| Timeout/lỗi mạng hoặc HTTP ngoài `2xx` | Tự retry; `3xx` không được tự chuyển hướng, `4xx` cũng retry hiện tại |
| Hết 8 lần gửi tổng cộng | `failed`, dừng tự động |
| Thiếu URL/token hoặc ứng dụng không còn đủ quyền hoạt động | `skipped`, không tự retry |

Khoảng chờ sau mỗi lần thất bại: **1 phút, 2 phút, 5 phút, 10 phút, 30 phút, 60 phút, 120 phút**. Mỗi request có timeout tổng 8 giây. HTTP `200` kèm body `{"success":false}` vẫn được tính thành công: worker không đọc JSON phản hồi.

Nút **Gửi lại** trong lịch sử sự kiện cho phép gửi lại event `failed`/`skipped` sau khi sửa cấu hình; giữ nguyên event ID và đặt lại bộ đếm lần gửi. Cấu hình URL mới không tự phát lại toàn bộ lịch sử `skipped`.

Hệ thống dùng hàng đợi bền vững và worker chạy liên tục để gửi event mới và retry đến hạn. Không cam kết thời gian giao cố định khi có backlog hoặc endpoint phản hồi chậm. Có thể nhận trùng nếu bên nhận đã lưu nhưng phản hồi bị mất, nên luôn chống trùng.

## Kiểm tra với Webhook.site

So sánh `event_id` trong JSON, không dùng mã request của công cụ. Nhiều request với ID khác nhau có thể là các bước của cùng phiên. Nếu cùng event ID lặp, xem **Số lần gửi / HTTP** trong lịch sử sự kiện Pay2S. Chỉ gửi thử dữ liệu phù hợp với môi trường thử nghiệm.


## Request và response đối chiếu ngày 11/10/2026

Các mẫu dưới đây giữ cấu trúc trường và kiểu dữ liệu. Khóa, chữ ký, URL phiên đã được thay bằng placeholder; danh sách chỉ giữ tối đa hai phần tử và dữ liệu ảnh/PDF/XML dài được rút gọn. Không sao chép placeholder để gọi API thật.

### Webhook Hosted Link · Chữ ký & retry

<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hosted-event">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=bankhub&api=hosted-event">Trải nghiệm BankHub Hosted Link ↗</a></div>

**Đối chiếu mã nguồn backend; giá trị minh họa.** Payload theo BankHubEvents.php. ACK minh họa từ receiver; webhook có Bearer token và chữ ký HMAC trên raw body.

`POST {webhook_url}`

Body:

```json
{
  "event_id": "0123456789abcdef0123456789abcdef",
  "version": "1.0",
  "timestamp": 1791691200,
  "environment": "uat",
  "event": "BANK_ACCOUNT_LINKED",
  "metadata": {
    "session_id": "SESSION_ID",
    "application_id": 2,
    "customer_reference": "CUSTOMER_DEMO",
    "bank_code": "ACB",
    "status": "connected",
    "purpose": "LINK_BANK_ACCOUNT",
    "state": "FINISHED_BANK_ACCOUNT_LINK",
    "unlink_mode": "bank",
    "account": {
      "id": 1,
      "bank_code": "ACB",
      "account_number": "P2S99999999",
      "user_bank_id": 1,
      "simulated": 1
    },
    "bank_profile": "acb_personal",
    "account_type": "personal",
    "connection_type": "openapi"
  },
  "sequence": 1
}
```

HTTP 200. Response:

```json
{
  "success": true
}
```