Webhook / Thông báo giao dịch tự động
Nội dung Markdown đầy đủ của trang tài liệu.
# 🔔 Webhook / Thông báo giao dịch tự động
> Trang này mô tả webhook **giao dịch tiền vào/ra**. Thông báo chờ OTP, hoàn tất liên kết, bật/tắt hoặc ngắt tài khoản được hướng dẫn riêng tại [Webhook sự kiện Hosted Link](./webhook-events.md); cách xác thực và phản hồi của hai loại webhook khác nhau.
> Đối chiếu mã nguồn Production ngày 11/10/2026. UAT chưa hỗ trợ bộ quản lý webhook; Playground mô phỏng cấu trúc này. Bỏ `webhook_url` khi tạo để dùng URL/token mặc định của Partner.
## 🧭 Giới thiệu
**Webhook Partner API** cho phép đối tác nhận thông báo **tự động (IN/OUT)** mỗi khi có giao dịch mới phát sinh từ các tài khoản ngân hàng đã liên kết.
Đây là cơ chế **push notification** theo thời gian thực — giúp hệ thống đối tác xử lý thanh toán, đối soát, hoặc cập nhật hóa đơn tức thì.
---
## 🔐 Header chung
```
Authorization: Bearer <token>
Content-Type: application/json
```
---
## 1️⃣ Tạo webhook mới
**POST** `https://api-partner.pay2s.vn/v1/webhooks`
### **Body mẫu**
```json
{
"user_bank_id": 1,
"webhook_url": "https://merchant.example.com/webhook",
"type": "IN"
}
```
### **Giải thích**
| Trường | Mô tả |
|--------|-------|
| `user_bank_id` | ID của tài khoản ngân hàng cần gắn webhook (lấy từ `/v1/banks`) |
| `webhook_url` | URL nhận dữ liệu giao dịch |
| `type` | Loại giao dịch cần nhận (`IN`, `OUT`, `ALL`) |
### **Phản hồi mẫu**
```json
{
"status": true,
"message": "Webhook đã được thêm thành công.",
"uses_custom_config": true,
"webhook_url": "https://merchant.example.com/webhook",
"token": "<WEBHOOK_TOKEN>",
"data": {
"config_type": "custom",
"webhook_info": "Webhook này có cấu hình riêng. Sử dụng token này để xác thực requests."
}
}
```
---
## 2️⃣ Danh sách webhook
**GET** `https://api-partner.pay2s.vn/v1/webhooks`
### **Phản hồi mẫu**
```json
{
"status": true,
"data": [
{
"id": 1,
"user_bank_id": 1,
"type": "IN",
"created_at": "2026-10-11 12:00:00",
"updated_at": "2026-10-11 12:00:00",
"status": "1",
"username": "",
"accountNumber": "P2S99999999",
"vaNumber": "",
"bankName": "Ngân hàng Á Châu",
"shortBankName": "ACB",
"vaNumberFull": "",
"webhook_url": "https://merchant.example.com/webhook",
"token": "<WEBHOOK_TOKEN>",
"uses_custom_config": true,
"has_own_token": true
}
]
}
```
---
## 3️⃣ Cập nhật webhook
**PUT** `https://api-partner.pay2s.vn/v1/webhooks/{id}`
### **Body mẫu**
```json
{
"webhook_url": "https://merchant.example.com/webhook",
"type": "ALL"
}
```
**Phản hồi:**
```json
{
"status": true,
"message": "Webhook đã được cập nhật thành công."
}
```
---
## 4️⃣ Bật / Tắt trạng thái webhook
**PATCH** `https://api-partner.pay2s.vn/v1/webhooks/{id}/toggle`
> Cho phép nhanh chóng **vô hiệu hóa / kích hoạt** webhook mà không cần xóa.
### **Phản hồi mẫu**
```json
{
"status": true,
"message": "Trạng thái webhook đã được cập nhật thành công."
}
```
---
## 5️⃣ Xoá webhook
**DELETE** `https://api-partner.pay2s.vn/v1/webhooks/{id}`
### **Phản hồi mẫu**
```json
{
"status": true,
"message": "Webhook đã được xóa thành công."
}
```
> Khi xoá, hệ thống cũng sẽ xoá các bản ghi liên quan trong `transaction_webhook_history` để tránh trùng lặp.
---
## 6️⃣ Lịch sử gọi webhook
**GET** `https://api-partner.pay2s.vn/v1/webhooks/history`
Hiển thị toàn bộ các lần hệ thống Pay2S đã **gửi thông báo giao dịch** đến endpoint webhook của bạn.
### **Phản hồi mẫu**
```json
{
"status": true,
"data": [
{
"id": 1,
"webhook_id": 1,
"endpoint": "https://merchant.example.com/webhook",
"payload": "{\"transactions\":[{\"id\":10001,\"gateway\":\"ACB\",\"transactionDate\":\"2026-10-11 09:00:00\",\"transactionNumber\":\"DEMO10001\",\"accountNumber\":\"P2S99999999\",\"content\":\"THANHTOAN DEMO\",\"transferType\":\"IN\",\"transferAmount\":2000,\"checksum\":\"demo-user-webhook-10001\"}]}",
"response": "{\"success\":true}",
"status_code": "200",
"status": 1,
"call_time": "2026-10-11 12:00:00"
}
]
}
```
---
## 7️⃣ Gửi lại webhook (Resend)
**POST** `https://api-partner.pay2s.vn/v1/webhooks/history/{history_id}/resend`
> Cho phép đối tác yêu cầu **Pay2S gửi lại payload** của giao dịch bị lỗi (ví dụ status_code ≠ 200).
### **Phản hồi mẫu**
```json
{
"status": true,
"message": "Gửi lại giao dịch thành công và cập nhật trạng thái webhook."
}
```
---
## 🔁 Cấu trúc Payload gửi đến Webhook của bạn
Khi có giao dịch mới, Pay2S sẽ gửi **POST** tới `webhook_url` mà bạn đã đăng ký.
### **Payload mẫu**
```json
{
"transactions": [
{
"id": 10001,
"gateway": "ACB",
"transactionDate": "2026-10-11 09:00:00",
"transactionNumber": "DEMO10001",
"accountNumber": "P2S99999999",
"content": "THANHTOAN DH10001",
"transferType": "IN",
"transferAmount": 2000,
"checksum": "demo-user-webhook-10001",
"paymentCode": "DH10001",
"extraData": {
"remitterName": "NGUYEN VAN A",
"remitterAccountNumber": "0123456789",
"issuerBankName": "Ngan hang TMCP Quan Doi",
"reciprocalBankCode": "970422"
}
}
]
}
```
### **Header gửi đi**
```
Content-Type: application/json
Authorization: Bearer <webhook_token>
```
### **Phản hồi yêu cầu**
Để đánh dấu nhận thành công, endpoint webhook của bạn cần trả về:
```json
{ "success": true }
```
Nếu trả về khác (`success=false`, hoặc HTTP 4xx/5xx), hệ thống sẽ ghi log lỗi và có thể cho phép **gửi lại thủ công** qua API `resend`.
---
## 🧪 cURL mẫu
### Tạo Webhook
```bash
curl -X POST https://api-partner.pay2s.vn/v1/webhooks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"user_bank_id": 25,
"webhook_url": "https://merchant-domain.com/pay2s/handler",
"type": "IN"
}'
```
### Danh sách Webhook
```bash
curl -X GET https://api-partner.pay2s.vn/v1/webhooks \
-H "Authorization: Bearer <token>"
```
### Gửi lại giao dịch
```bash
curl -X POST https://api-partner.pay2s.vn/v1/webhooks/history/991/resend \
-H "Authorization: Bearer <token>"
```
---
::: tip Ghi chú
- Mỗi webhook được cấp **token riêng** (trường `token` trong dữ liệu webhook). Khi Pay2S gọi webhook của bạn, token này nằm trong header Authorization.
- Hệ thống ghi log mọi lần gửi — có thể truy xuất qua `/v1/webhooks/history`.
:::
## Trường bổ sung: paymentCode, người chuyển và tài khoản ảo
Các trường bên dưới nằm trong **từng phần tử `transactions[]`**, không nằm ở ngoài mảng. Payload dưới đây là webhook giao dịch, không phải IPN thanh toán hoặc webhook trạng thái Hosted Link.
### Có mã thanh toán và thông tin người chuyển
```json
{
"transactions": [
{
"id": 10001,
"gateway": "ACB",
"transactionDate": "2026-10-11 09:00:00",
"transactionNumber": "DEMO10001",
"accountNumber": "P2S99999999",
"content": "THANHTOAN DH10001",
"transferType": "IN",
"transferAmount": 2000,
"checksum": "demo-user-webhook-10001",
"paymentCode": "DH10001",
"extraData": {
"remitterName": "NGUYEN VAN A",
"remitterAccountNumber": "0123456789",
"issuerBankName": "Ngan hang TMCP Quan Doi",
"reciprocalBankCode": "970422"
}
}
]
}
```
| Trường | Vị trí / kiểu dữ liệu | Khi nào xuất hiện? |
| --- | --- | --- |
| `paymentCode` | `transactions[i].paymentCode` · chuỗi | Webhook có yêu cầu mã thanh toán, đã chọn mẫu và nội dung giao dịch khớp mẫu. Worker trích mã từ nội dung, ví dụ `DH10001`; đây không phải mã giao dịch ngân hàng. |
| `extraData` | `transactions[i].extraData` · object | Bật dữ liệu bổ sung (`extraData`) trong cấu hình webhook. Tắt thì cả object bị bỏ khỏi payload. Không phải chuỗi Base64 `extraData` của API tạo thanh toán. |
| `remitterName` | `extraData.remitterName` · chuỗi | Tên người chuyển do ngân hàng cung cấp. |
| `remitterAccountNumber` | `extraData.remitterAccountNumber` · chuỗi | Số tài khoản người chuyển. Giữ dạng chuỗi để không mất số 0 đầu. |
| `issuerBankName` | `extraData.issuerBankName` · chuỗi | Tên ngân hàng trong dữ liệu người chuyển. |
| `reciprocalBankCode` | `extraData.reciprocalBankCode` · chuỗi | Mã ngân hàng đối ứng do nguồn giao dịch cung cấp; không tự suy ra từ `gateway`. |
| `billNumber` | `transactions[i].billNumber` · thường là chuỗi | Giao dịch có `billNumber` có giá trị. Worker chuyển nguyên giá trị, không sinh cho tất cả giao dịch. |
| `partnerRefId` | `transactions[i].partnerRefId` · thường là chuỗi | Giao dịch có mã tham chiếu đối tác. |
| `TID` | `transactions[i].TID` · giá trị VA, có thể null | Được thêm **cùng `partnerRefId`**, lấy từ `vaNumber` của giao dịch. Đúng tên trường là `TID` viết hoa. |
| `teller` | `transactions[i].teller` · thường là chuỗi | Nguồn giao dịch có giá trị `teller`. |
| `sequence` | `transactions[i].sequence` · giá trị từ nguồn, có thể null | Được thêm **cùng `teller`**. Nếu giá trị là `undefined` thì JSON có thể bỏ trường này. |
**Thiếu thông tin người chuyển:** khi đã bật `extraData`, worker thay giá trị `null` hoặc không có bằng chuỗi **`"N/A"`**. Chuỗi rỗng từ ngân hàng vẫn được giữ nguyên. Không coi `N/A` là tên hoặc số tài khoản thật, không bắt buộc có thông tin người chuyển mới ghi nhận một giao dịch hợp lệ.
### Bật extraData nhưng ngân hàng không cung cấp thông tin
```json
{
"transactions": [
{
"id": 10001,
"gateway": "ACB",
"transactionDate": "2026-10-11 09:00:00",
"transactionNumber": "DEMO10001",
"accountNumber": "P2S99999999",
"content": "THANHTOAN DH10001",
"transferType": "IN",
"transferAmount": 2000,
"checksum": "demo-user-webhook-10001",
"extraData": {
"remitterName": "N/A",
"remitterAccountNumber": "N/A",
"issuerBankName": "N/A",
"reciprocalBankCode": "N/A"
}
}
]
}
```
### Tài khoản ảo và mã tham chiếu đối tác
```json
{
"transactions": [
{
"id": 10001,
"gateway": "BIDV",
"transactionDate": "2026-10-11 09:00:00",
"transactionNumber": "DEMO10001",
"accountNumber": "963869123456",
"content": "THANHTOAN DH10001",
"transferType": "IN",
"transferAmount": 2000,
"checksum": "demo-user-webhook-10001",
"partnerRefId": "PARTNER_REF_DEMO",
"TID": "123456"
}
]
}
```
Theo worker hiện tại, nếu giao dịch có `vaNumber` thì **`accountNumber = "963869" + vaNumber`**; nếu không, `accountNumber` là tài khoản gốc. `TID` giữ giá trị `vaNumber` gốc và chỉ đi kèm nhánh `partnerRefId`. Không tự thêm tiền tố lần nữa hoặc chuyển số tài khoản/TID sang kiểu số. Webhook gửi trực tiếp không có trường `vaNumber` riêng trong nhánh này; payload dựng lại từ API lịch sử webhook có thể có trường đó và `accountNumber` là tài khoản gốc.
### Ví dụ liệt kê toàn bộ trường có điều kiện
Mẫu này ghép các nhánh của worker để minh họa tên và vị trí trường; **không có nghĩa một ngân hàng hoặc mọi giao dịch luôn trả đủ các trường này**. Các giá trị là dữ liệu thử, không phải bản chụp giao dịch ngân hàng thật.
```json
{
"transactions": [
{
"id": 10001,
"gateway": "ACB",
"transactionDate": "2026-10-11 09:00:00",
"transactionNumber": "DEMO10001",
"accountNumber": "963869123456",
"content": "THANHTOAN DH10001",
"transferType": "IN",
"transferAmount": 2000,
"checksum": "demo-user-webhook-10001",
"paymentCode": "DH10001",
"billNumber": "BILL_DEMO_001",
"partnerRefId": "PARTNER_REF_DEMO",
"TID": "123456",
"teller": "TELLER_DEMO",
"sequence": "000123",
"extraData": {
"remitterName": "NGUYEN VAN A",
"remitterAccountNumber": "0123456789",
"issuerBankName": "Ngan hang TMCP Quan Doi",
"reciprocalBankCode": "970422"
}
}
]
}
```
### Đọc dữ liệu mà không phụ thuộc trường tùy chọn
```js
for (const transaction of payload.transactions) {
const paymentCode = transaction.paymentCode ?? null;
const extra = transaction.extraData ?? {};
const remitterName = extra.remitterName && extra.remitterName !== 'N/A'
? extra.remitterName : null;
const remitterAccount = extra.remitterAccountNumber && extra.remitterAccountNumber !== 'N/A'
? extra.remitterAccountNumber : null;
// Lưu giao dịch có chống trùng theo id/checksum.
// paymentCode, remitterName, remitterAccount có thể không có.
}
```
Kiểm tra Bearer token trước khi xử lý; lưu giao dịch hoặc đưa vào hàng đợi bền vững trước khi ACK. Receiver nên chấp nhận các trường bổ sung mới, không từ chối toàn bộ giao dịch chỉ vì thiếu trường tùy chọn.
## 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 · Tạo
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-create">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-create">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng. Có thể bỏ webhook_url để dùng cấu hình mặc định của partner.
`POST /v1/webhooks`
Body:
```json
{
"user_bank_id": 1,
"webhook_url": "https://merchant.example.com/webhook",
"type": "IN"
}
```
HTTP 200. Response:
```json
{
"status": true,
"message": "Webhook đã được thêm thành công.",
"uses_custom_config": true,
"webhook_url": "https://merchant.example.com/webhook",
"token": "<WEBHOOK_TOKEN>",
"data": {
"config_type": "custom",
"webhook_info": "Webhook này có cấu hình riêng. Sử dụng token này để xác thực requests."
}
}
```
### Webhook · Danh sách
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-list">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-list">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng.
`GET /v1/webhooks`
Không gửi JSON body.
HTTP 200. Response:
```json
{
"status": true,
"data": [
{
"id": 1,
"user_bank_id": 1,
"type": "IN",
"created_at": "2026-10-11 12:00:00",
"updated_at": "2026-10-11 12:00:00",
"status": "1",
"username": "",
"accountNumber": "P2S99999999",
"vaNumber": "",
"bankName": "Ngân hàng Á Châu",
"shortBankName": "ACB",
"vaNumberFull": "",
"webhook_url": "https://merchant.example.com/webhook",
"token": "<WEBHOOK_TOKEN>",
"uses_custom_config": true,
"has_own_token": true
}
]
}
```
### Webhook · Cập nhật
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-update">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-update">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng.
`PUT /v1/webhooks/{id}`
Body:
```json
{
"webhook_url": "https://merchant.example.com/webhook",
"type": "ALL"
}
```
HTTP 200. Response:
```json
{
"status": true,
"message": "Webhook đã được cập nhật thành công."
}
```
### Webhook · Bật / tắt
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-toggle">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-toggle">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng.
`PATCH /v1/webhooks/{id}/toggle`
Không gửi JSON body.
HTTP 200. Response:
```json
{
"status": true,
"message": "Trạng thái webhook đã được cập nhật thành công."
}
```
### Webhook · Xóa
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-delete">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-delete">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng.
`DELETE /v1/webhooks/{id}`
Không gửi JSON body.
HTTP 200. Response:
```json
{
"status": true,
"message": "Webhook đã được xóa thành công."
}
```
### Webhook · Lịch sử
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-history">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-history">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng. payload và response là chuỗi JSON.
`GET /v1/webhooks/history`
Không gửi JSON body.
HTTP 200. Response:
```json
{
"status": true,
"data": [
{
"id": 1,
"webhook_id": 1,
"endpoint": "https://merchant.example.com/webhook",
"payload": "{\"transactions\":[{\"id\":10001,\"gateway\":\"ACB\",\"transactionDate\":\"2026-10-11 09:00:00\",\"transactionNumber\":\"DEMO10001\",\"accountNumber\":\"P2S99999999\",\"content\":\"THANHTOAN DEMO\",\"transferType\":\"IN\",\"transferAmount\":2000,\"checksum\":\"demo-user-webhook-10001\"}]}",
"response": "{\"success\":true}",
"status_code": "200",
"status": 1,
"call_time": "2026-10-11 12:00:00"
}
]
}
```
### Webhook · Gửi lại
<div class="doc-api-actions" data-doc-api-actions><a class="try-button" data-doc-playground href="/playground/?api=hook-resend">Thử API trong Playground ↗</a><a class="try-button experience-button" data-doc-experience href="/demos/?flow=webhook&api=hook-resend">Trải nghiệm nhận webhook ↗</a></div>
**Đối chiếu mã nguồn backend; giá trị minh họa.** Theo HookController.php (Production). UAT chưa hỗ trợ, Playground mô phỏng.
`POST /v1/webhooks/history/{history_id}/resend`
Không gửi JSON body.
HTTP 200. Response:
```json
{
"status": true,
"message": "Gửi lại giao dịch thành công và cập nhật trạng thái webhook."
}
```