<!-- pay2s-offline-docs-v1 -->
# Tài liệu Pay2S đính kèm

Ngày chụp nội dung tài liệu: 2026-09-29 (UTC).
Nguồn: https://docs.pay2s.vn/

Đây là bản tài liệu lưu tại ngày trên, không tự cập nhật sau khi tải. Nội dung các trang được chép đầy đủ bên dưới; không cần mở link để đọc. Các liên kết khác là nguồn tham khảo bổ sung. Nếu thiếu thông tin cần thiết, hãy hỏi người dùng thay vì tự đoán.

## Mục lục
- 🪝 Tạo Hook
- 🧠 Khái niệm & Tích hợp Hook Pay2S
- 📚 Tài liệu kỹ thuật Webhook

---

Nguồn trang: https://docs.pay2s.vn/webhook/create-hook.html

# 🪝 Tạo Hook

## 🔐 Token là gì?

Token được tạo **một lần duy nhất** trong quá trình khai báo Hook trên hệ thống [Pay2S.vn](https://pay2s.vn). Token giúp xác minh rằng dữ liệu giao dịch được gửi từ Pay2S là **chính xác và hợp lệ**.

### Định dạng Request Header

```
Content-Type: application/json
Authorization: Bearer <SecretKey>
```

> **⚠️ Lưu ý**: Token này chỉ được hiển thị duy nhất một lần khi bạn tạo Hook. Lưu giữ an toàn!

---

## 🛠️ Cách tạo Webhook

### 1️⃣ Truy cập Webhooks

- Vào khu vực `Webhooks` → `Thêm webhook`
- Bạn sẽ thấy giao diện như sau:

![Thêm Webhook](/assets/add-hook.png)

### 2️⃣ Nhập các thông tin cần thiết

| Trường | Bắt buộc | Mô tả |
|--------|---------|-------|
| Tài khoản | ✓ | Tài khoản liên kết sẽ gửi dữ liệu |
| Sự kiện | ✓ | Loại giao dịch (tất cả, nhận, chuyển) |
| Endpoint | ✓ | URL nhận dữ liệu (http/https) |

### 3️⃣ Mô tả chi tiết

#### **Chọn tài khoản**
- Chọn tài khoản ngân hàng đã liên kết
- **Lưu ý**: Bạn cần liên kết tài khoản trước khi tạo Hook

#### **Chọn sự kiện**
- **Tất cả**: Gửi cả giao dịch nhận và chuyển
- **Nhận tiền**: Chỉ gửi khi có tiền vào
- **Chuyển tiền**: Chỉ gửi khi có tiền ra

#### **Endpoint**
- URL địa chỉ nhận dữ liệu từ Pay2S
- Dùng `https://` nếu website có SSL
- Dùng `http://` nếu không có SSL
- Phải trả về HTTP 200 để xác nhận

---

## 🔁 Chu kỳ gửi dữ liệu

### ⏱️ Cơ chế Retry

- **Gửi ngay**: Ngay lập tức khi có giao dịch
- **Retry 1**: Sau 60 giây nếu chưa nhận được 200
- **Retry 2-5**: Tiếp tục retry 5 lần nếu lỗi
- **Dừng**: Khi nhận được HTTP 200 hoặc sau lần gửi thứ 6

### ✅ Điều kiện dừng

Pay2S sẽ ngừng gửi nếu:

1. Nhận được **HTTP 200** hoặc
2. Response body chứa:

```json
{
  "success": true
}
```

---

## 💻 Ví dụ xử lý Webhook

::: code-group

```php [PHP]
<?php
// Lấy header Authorization
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? '';

// Kiểm tra token
$token = 'YOUR_SECRET_KEY_HERE';
if ($authHeader !== 'Bearer ' . $token) {
    http_response_code(401);
    echo json_encode(['success' => false]);
    exit;
}

// Lấy dữ liệu JSON từ body
$data = json_decode(file_get_contents('php://input'), true);

// Xử lý dữ liệu giao dịch
$id = $data['id'];
$transactionNumber = $data['transactionNumber'];
$amount = $data['transferAmount'];
$content = $data['content'];

// Lưu vào database
// INSERT INTO transactions (id, transaction_number, amount, content) VALUES ($id, $transactionNumber, $amount, $content);

// Trả về response
http_response_code(200);
echo json_encode(['success' => true]);
?>
```

```javascript [Node.js]
const express = require('express');
const app = express();

app.use(express.json());

const SECRET_KEY = 'YOUR_SECRET_KEY_HERE';

app.post('/webhook', (req, res) => {
  // Kiểm tra header Authorization
  const authHeader = req.headers['authorization'] || '';
  if (authHeader !== `Bearer ${SECRET_KEY}`) {
    return res.status(401).json({ success: false });
  }

  // Lấy dữ liệu từ body
  const { id, transactionNumber, transferAmount, content } = req.body;

  // Xử lý dữ liệu giao dịch
  console.log('Transaction received:', {
    id,
    transactionNumber,
    transferAmount,
    content
  });

  // Lưu vào database
  // await Transaction.create({ id, transactionNumber, transferAmount, content });

  // Trả về response
  return res.status(200).json({ success: true });
});

app.listen(3000, () => console.log('Webhook server running on port 3000'));
```

```python [Python]
from flask import Flask, request, jsonify
import os

app = Flask(__name__)
SECRET_KEY = 'YOUR_SECRET_KEY_HERE'

@app.route('/webhook', methods=['POST'])
def webhook():
    # Kiểm tra header Authorization
    auth_header = request.headers.get('Authorization', '')
    if auth_header != f'Bearer {SECRET_KEY}':
        return jsonify({'success': False}), 401

    # Lấy dữ liệu JSON
    data = request.get_json()
    
    transaction_id = data.get('id')
    transaction_number = data.get('transactionNumber')
    amount = data.get('transferAmount')
    content = data.get('content')

    # Xử lý dữ liệu giao dịch
    print(f'Transaction: {transaction_id}, Amount: {amount}')

    # Lưu vào database
    # db.transaction.insert_one({ 'id': transaction_id, 'transaction_number': transaction_number, 'amount': amount, 'content': content })

    return jsonify({'success': True}), 200

if __name__ == '__main__':
    app.run(port=5000, debug=False)
```

```java [Java]
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import com.google.gson.Gson;
import java.util.Map;

@RestController
@RequestMapping("/webhook")
public class WebhookController {
    
    private static final String SECRET_KEY = "YOUR_SECRET_KEY_HERE";
    
    @PostMapping
    public ResponseEntity<?> handleWebhook(
            @RequestHeader(value = "Authorization", required = false) String authHeader,
            @RequestBody Map<String, Object> data) {
        
        // Kiểm tra header Authorization
        if (authHeader == null || !authHeader.equals("Bearer " + SECRET_KEY)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of("success", false));
        }
        
        // Lấy dữ liệu giao dịch
        String id = (String) data.get("id");
        String transactionNumber = (String) data.get("transactionNumber");
        Long amount = ((Number) data.get("transferAmount")).longValue();
        String content = (String) data.get("content");
        
        // Xử lý dữ liệu
        System.out.println("Transaction: " + id + ", Amount: " + amount);
        
        // Lưu vào database
        // transactionService.save(new Transaction(id, transactionNumber, amount, content));
        
        return ResponseEntity.ok(Map.of("success", true));
    }
}
```

```go [Go]
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strings"
)

const SECRET_KEY = "YOUR_SECRET_KEY_HERE"

type WebhookData struct {
    ID                string `json:"id"`
    TransactionNumber string `json:"transactionNumber"`
    TransferAmount    int64  `json:"transferAmount"`
    Content           string `json:"content"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Kiểm tra Authorization header
    authHeader := r.Header.Get("Authorization")
    if authHeader != "Bearer "+SECRET_KEY {
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]bool{"success": false})
        return
    }

    // Đọc và parse JSON body
    var data WebhookData
    if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]bool{"success": false})
        return
    }

    // Xử lý dữ liệu giao dịch
    log.Printf("Transaction received: %s, Amount: %d", data.ID, data.TransferAmount)

    // Trả về response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]bool{"success": true})
}

func main() {
    http.HandleFunc("/webhook", webhookHandler)
    log.Println("Webhook server running on :8080")
    http.ListenAndServe(":8080", nil)
}
```

```ruby [Ruby]
require 'sinatra'
require 'json'

SECRET_KEY = 'YOUR_SECRET_KEY_HERE'

post '/webhook' do
  # Kiểm tra Authorization header
  auth_header = request.headers['Authorization'] || ''
  if auth_header != "Bearer #{SECRET_KEY}"
    status 401
    return { success: false }.to_json
  end

  # Lấy dữ liệu JSON
  data = JSON.parse(request.body.read)
  
  id = data['id']
  transaction_number = data['transactionNumber']
  amount = data['transferAmount']
  content = data['content']

  # Xử lý dữ liệu giao dịch
  puts "Transaction received: #{id}, Amount: #{amount}"

  # Lưu vào database
  # Transaction.create(id: id, transaction_number: transaction_number, amount: amount, content: content)

  status 200
  { success: true }.to_json
end
```

:::

---

## 📌 Test Webhook

### Cách test

1. **Tạo giao dịch test** trên hệ thống Pay2S
2. **Kiểm tra logs** endpoint của bạn để xem dữ liệu nhận được
3. **Xem lịch sử** tại mục **Lịch sử giao dịch** trên dashboard Pay2S

### Công cụ test

Dùng Postman hoặc curl để test:

```bash
curl -X POST https://your-endpoint.com/webhook \
  -H "Authorization: Bearer YOUR_SECRET_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "test123",
    "gateway": "momo",
    "transactionDate": "2024-01-25",
    "transactionNumber": "TXN001",
    "accountNumber": "0123456789",
    "content": "Test payment",
    "transferType": "IN",
    "transferAmount": 100000,
    "checksum": "abc123"
  }'
```

---

## ⚡ Best Practices

1. **Bảo mật**: Lưu token ở environment variables, không hardcode
2. **Validation**: Luôn kiểm tra Authorization header trước khi xử lý
3. **Idempotency**: Xử lý được webhook gửi nhiều lần (lưu transaction ID)
4. **Response nhanh**: Trả về 200 trong 30 giây, offload processing vào queue
5. **Logging**: Log tất cả webhook để debug và audit
6. **HTTPS**: Luôn dùng HTTPS cho endpoint
7. **Error handling**: Đừng trả về 200 nếu có lỗi database
8. **Monitoring**: Alert khi webhook failed hoặc timeout
9. **Database transaction**: Dùng transaction để tránh race condition
10. **Rate limiting**: Đừng bị dồn request từ Pay2S (nếu endpoint lỗi)


---

Nguồn trang: https://docs.pay2s.vn/webhook/khai-niem.html

# 🧠 Khái niệm & Tích hợp Hook Pay2S

## 🔌 Tích hợp nhanh chóng

Việc tích hợp cực kỳ đơn giản với **PHP, Node.js** hoặc bất kỳ ngôn ngữ nào.  
Bạn chỉ cần khai báo đầu nhận dữ liệu (`endpoint`) và xử lý kết quả được Pay2S gửi về.

---

## 🏦 Liên kết tài khoản

Bạn cần có tài khoản Pay2S và **liên kết ít nhất một tài khoản ngân hàng**.  
Truy cập **trang quản trị** để xem danh sách tài khoản liên kết.

---

## Giao diện liên kết

Hệ thống có thể hỗ trợ liên kết cùng lúc nhiều tài khoản. Việc tích hợp gần như không phụ thuộc vào việc bạn liên kết tài khoản nào vào hệ thống nhờ tính linh động của hệ thống Hook.

## ⚙️ Tạo Hook

Sau khi liên kết tài khoản, bạn tiến hành **khai báo Hook** – là endpoint của bạn để nhận dữ liệu giao dịch.

Chi tiết vui lòng tham khảo ở mục [Tạo Hook](/webhook/create-hook) !

## 📩 Cơ chế gửi giao dịch

- Mỗi giao dịch được gửi **riêng lẻ**: nếu có 5 giao dịch thì gửi 5 lần
- Bạn có thể theo dõi tại [**Lịch sử giao dịch**](https://my.pay2s.vn/history_webhook) để debug

> **Mẹo**: Tạo 1 giao dịch test → Xem dữ liệu tại Lịch sử giao dịch để kiểm tra endpoint

## 🔐 Bảo mật với API Key

Dữ liệu được gửi ở định dạng **JSON** (raw body) với Header:

```
Authorization: Bearer [Token của Hook]
```

> **⚠️ Lưu ý**: Token được gửi trong Header `Authorization`. Bạn <b>PHẢI</b> kiểm tra và so sánh Token này trước khi xử lý dữ liệu giao dịch để đảm bảo request từ Pay2S thực sự.

## 📦 Dữ liệu giao dịch gửi về từ Pay2S

Dưới đây là các trường được gửi từ hệ thống:

| Trường | Kiểu | Mô tả |
|--------|------|-------|
| id | String | Mã định danh duy nhất tại Pay2S |
| gateway | String | Tên cổng thanh toán (momo, vcb, techcombank...) |
| transactionDate | Date | Ngày tháng giao dịch (yyyy-mm-dd) |
| transactionNumber | String | Mã giao dịch riêng biệt, không trùng lặp trên cùng tài khoản |
| accountNumber | String | Số tài khoản liên kết nhận giao dịch |
| content | String | Nội dung giao dịch từ cổng thanh toán |
| transferType | String | Loại giao dịch: `IN` (nhận) hoặc `OUT` (chuyển) |
| transferAmount | Long | Số tiền giao dịch (dạng số nguyên, vd: 1000000) |
| checksum | String | Mã xác định duy nhất giao dịch được sinh ra từ Pay2S |

## ⚡ Best Practices

1. **Xác thực Token**: Luôn check header `Authorization` trước khi xử lý
2. **Verify checksum**: Kiểm tra tính toàn vẹn dữ liệu bằng checksum
3. **Idempotent**: Xử lý webhook nhiều lần mà không gây lỗi (check `id` hoặc `transactionNumber`)
4. **Response nhanh**: Trả về HTTP 200 trong vòng 30 giây
5. **Async processing**: Offload xử lý nặng vào queue/background job
6. **Logging**: Log tất cả request webhook để debug
7. **Retry logic**: Pay2S sẽ retry nếu không nhận được 200 OK
8. **HTTPS Only**: Endpoint phải dùng HTTPS
9. **Database transaction**: Dùng transaction khi cập nhật database
10. **Monitoring**: Alert khi có lỗi hoặc timeout


---

Nguồn trang: https://docs.pay2s.vn/webhook/tai-lieu-ky-thuat.html

# 📚 Tài liệu kỹ thuật Webhook

## 🔄 Cơ chế hoạt động

Khi một giao dịch xảy ra, Pay2S sẽ gửi dữ liệu đến endpoint của bạn qua HTTP POST.

---

## 🛠️ Request từ Pay2S

### Method
```
POST
```

### Header

```json
{
  "Content-Type": "application/json",
  "Authorization": "Bearer <YOUR_SECRET_KEY>"
}
```

### Body (POST Parameters)

```json
{
  "transactions": [
    {
      "id": "1788052",
      "gateway": "ACB",
      "transactionDate": "2025-04-01 00:02:18",
      "transactionNumber": "10418",
      "accountNumber": "12805521",
      "content": "SHOPVPS12537 GD 789604-040125 00:05:38",
      "transferType": "IN",
      "transferAmount": 50000,
      "checksum": "7e2b3bbc03d1083017e3d2a96d3b8e01"
    }
  ]
}
```

### Response từ Endpoint của bạn

```json
{
  "success": true
}
```

---

## 🔁 Cơ chế Retry

Pay2S sẽ thực hiện các bước sau:

| Bước | Thời gian | Hành động |
|------|-----------|----------|
| 1️⃣ Gửi ngay | Lập tức | Gửi dữ liệu đến endpoint |
| 2️⃣ Retry 1 | +60 giây | Gửi lại nếu không nhận 200 OK |
| 3️⃣ Retry 2-5 | +60 giây mỗi lần | Tiếp tục retry (tối đa 5 lần) |
| 4️⃣ Dừng | Sau lần 6 | Ngừng gửi |

### ✅ Điều kiện dừng gửi lại

Pay2S sẽ **dừng gửi** khi một trong hai điều kiện được thỏa:

1. **HTTP status: `200 OK`** hoặc
2. **Response body chứa**:

```json
{
  "success": true
}
```

> **💡 Tip**: Nếu endpoint xử lý lâu, hãy trả về 200 ngay và offload xử lý vào background job

---

## 💻 Code mẫu

::: code-group

```php [PHP]
<?php
// Token hợp lệ của bạn
$expectedToken = 'your_expected_token_here';

// Lấy Authorization header
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? '';

// Kiểm tra token
if (!preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
    http_response_code(401);
    echo json_encode(['success' => false]);
    exit;
}

$receivedToken = $matches[1];
if ($receivedToken !== $expectedToken) {
    http_response_code(403);
    echo json_encode(['success' => false]);
    exit;
}

// Lấy dữ liệu JSON
$data = json_decode(file_get_contents('php://input'), true);

if (!$data || !isset($data['transactions'])) {
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => 'Invalid payload']);
    exit;
}

// Xử lý từng giao dịch
foreach ($data['transactions'] as $transaction) {
    $id = $transaction['id'];
    $amount = $transaction['transferAmount'];
    $content = $transaction['content'];
    
    // Lưu vào database
    // INSERT INTO transactions (id, amount, content) VALUES ($id, $amount, $content);
}

// Trả về 200
http_response_code(200);
echo json_encode(['success' => true]);
?>
```

```javascript [Node.js]
const express = require('express');
const app = express();

app.use(express.json());

const SECRET_KEY = 'YOUR_SECRET_KEY';

app.post('/webhook', (req, res) => {
  // Kiểm tra Authorization
  const authHeader = req.headers['authorization'] || '';
  const token = authHeader.replace('Bearer ', '');
  
  if (token !== SECRET_KEY) {
    return res.status(403).json({ success: false });
  }

  const { transactions } = req.body;
  
  if (!transactions || !Array.isArray(transactions)) {
    return res.status(400).json({ success: false });
  }

  // Xử lý từng giao dịch
  transactions.forEach(tx => {
    const { id, transferAmount, content } = tx;
    // await Transaction.create({ id, transferAmount, content });
  });

  return res.status(200).json({ success: true });
});

app.listen(3000, () => console.log('Webhook running on :3000'));
```

```python [Python]
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET_KEY = 'YOUR_SECRET_KEY'

@app.route('/webhook', methods=['POST'])
def webhook():
    # Kiểm tra Authorization
    auth_header = request.headers.get('Authorization', '')
    token = auth_header.replace('Bearer ', '')
    
    if token != SECRET_KEY:
        return jsonify({'success': False}), 403

    data = request.get_json()
    
    if not data or 'transactions' not in data:
        return jsonify({'success': False}), 400
    
    # Xử lý từng giao dịch
    for tx in data['transactions']:
        tx_id = tx.get('id')
        amount = tx.get('transferAmount')
        content = tx.get('content')
        # db.transaction.insert_one({ 'id': tx_id, 'amount': amount, 'content': content })
    
    return jsonify({'success': True}), 200

if __name__ == '__main__':
    app.run(port=5000)
```

```java [Java]
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import com.google.gson.Gson;
import java.util.Map;
import java.util.List;

@RestController
@RequestMapping("/webhook")
public class WebhookController {
    
    private static final String SECRET_KEY = "YOUR_SECRET_KEY";
    
    @PostMapping
    public ResponseEntity<?> handleWebhook(
            @RequestHeader(value = "Authorization", required = false) String authHeader,
            @RequestBody Map<String, Object> data) {
        
        // Kiểm tra Authorization
        if (authHeader == null) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                    .body(Map.of("success", false));
        }
        
        String token = authHeader.replace("Bearer ", "");
        if (!token.equals(SECRET_KEY)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                    .body(Map.of("success", false));
        }

        // Lấy transactions
        List<Map<String, Object>> transactions = (List<Map<String, Object>>) data.get("transactions");
        
        if (transactions == null) {
            return ResponseEntity.badRequest().body(Map.of("success", false));
        }

        // Xử lý từng giao dịch
        for (Map<String, Object> tx : transactions) {
            String id = (String) tx.get("id");
            Long amount = ((Number) tx.get("transferAmount")).longValue();
            String content = (String) tx.get("content");
            // transactionService.save(new Transaction(id, amount, content));
        }

        return ResponseEntity.ok(Map.of("success", true));
    }
}
```

```go [Go]
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strings"
)

const SECRET_KEY = "YOUR_SECRET_KEY"

type Transaction struct {
    ID             string `json:"id"`
    TransferAmount int64  `json:"transferAmount"`
    Content        string `json:"content"`
}

type WebhookRequest struct {
    Transactions []Transaction `json:"transactions"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Kiểm tra Authorization
    authHeader := r.Header.Get("Authorization")
    token := strings.TrimPrefix(authHeader, "Bearer ")
    
    if token != SECRET_KEY {
        w.WriteHeader(http.StatusForbidden)
        json.NewEncoder(w).Encode(map[string]bool{"success": false})
        return
    }

    // Parse JSON body
    var webhook WebhookRequest
    if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]bool{"success": false})
        return
    }

    // Xử lý từng giao dịch
    for _, tx := range webhook.Transactions {
        log.Printf("Processing transaction: %s, Amount: %d", tx.ID, tx.TransferAmount)
        // db.SaveTransaction(tx)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]bool{"success": true})
}

func main() {
    http.HandleFunc("/webhook", webhookHandler)
    log.Println("Webhook server running on :8080")
    http.ListenAndServe(":8080", nil)
}
```

```ruby [Ruby]
require 'sinatra'
require 'json'

SECRET_KEY = 'YOUR_SECRET_KEY'

post '/webhook' do
  # Kiểm tra Authorization
  auth_header = request.headers['Authorization'] || ''
  token = auth_header.sub('Bearer ', '')
  
  if token != SECRET_KEY
    status 403
    return { success: false }.to_json
  end

  # Lấy dữ liệu
  data = JSON.parse(request.body.read)
  transactions = data['transactions']
  
  unless transactions.is_a?(Array)
    status 400
    return { success: false }.to_json
  end

  # Xử lý từng giao dịch
  transactions.each do |tx|
    id = tx['id']
    amount = tx['transferAmount']
    content = tx['content']
    # Transaction.create(id: id, amount: amount, content: content)
  end

  status 200
  { success: true }.to_json
end
```

:::

---

## 🔧 Troubleshooting

### ⚠️ Authorization header không được nhận

Nếu server của bạn không nhận được header `Authorization`, thêm rule này vào `.htaccess`:

```.htaccess
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
```

---

## ⚡ Best Practices

1. **Xác thực ngay**: Kiểm tra token trước khi xử lý
2. **Validate dữ liệu**: Kiểm tra `transactions` array trước khi loop
3. **Response nhanh**: Trả về 200 ngay, offload xử lý vào queue/background job
4. **Idempotent**: Xử lý webhook nhiều lần mà không bị lỗi (kiểm tra ID trước insert)
5. **Logging**: Log tất cả webhook request/response để debug
6. **Error handling**: Không trả về 200 nếu xử lý thất bại
7. **Timeout**: Đặt timeout vì Pay2S sẽ retry sau 60 giây
8. **Database transaction**: Dùng DB transaction để tránh data inconsistency
9. **Monitoring**: Alert nếu webhook failed nhiều lần
10. **Versioning**: Hỗ trợ multiple webhook versions để dễ update

---

## 📌 Checklist trước deploy

- [ ] Authorization header được kiểm tra
- [ ] JSON validation đầy đủ
- [ ] Response 200 được trả về luôn
- [ ] Logging được cấu hình
- [ ] Database transaction được dùng
- [ ] Error handling cho timeout/network error
- [ ] Endpoint có HTTPS (nếu production)
- [ ] Rate limiting được setup
- [ ] Webhook được test với Postman/curl
- [ ] Alert/monitoring được cấu hình

---

## Yêu cầu lập hóa đơn từ phản hồi Webhook

Endpoint có thể yêu cầu Pay2S lập hóa đơn điện tử cho giao dịch tiền vào bằng cách trả thêm metadata hóa đơn. Cấu trúc request Pay2S gửi đến endpoint không thay đổi. Nếu không sử dụng tính năng hóa đơn, endpoint tiếp tục phản hồi như hiện tại:

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

Nếu muốn yêu cầu Pay2S lập hóa đơn cho giao dịch vừa nhận, thêm `invoiceMetadata` vào cùng response:

```json
{
  "success": true,
  "invoiceMetadata": {
    "invoiceType": "regular",
    "invoiceOptions": {
      "requested": true,
      "buyerNotTakingInvoice": false,
      "paymentMethod": "TM/CK"
    },
    "customerInfo": {
      "buyerContactName": "Nguyễn Văn A",
      "buyerCompanyName": "",
      "taxCode": "",
      "citizenId": "079123456789",
      "address": "TP. Hồ Chí Minh",
      "email": "khach@example.com",
      "phone": "0900000000"
    },
    "items": [
      {
        "itemCode": "SP001",
        "itemGroupCode": "NHOM_SAN_PHAM",
        "itemName": "Sản phẩm A",
        "unit": "Cái",
        "quantity": 1,
        "unitPrice": 50000,
        "taxRate": -1
      }
    ]
  }
}
```

Lưu ý:

- Pay2S chỉ tiếp nhận metadata khi response có HTTP 2xx và `success: true`.
- Tài khoản phải sử dụng gói Basic trở lên, đã kích hoạt tính năng hóa đơn điện tử và bật `Nhận thông tin xuất hóa đơn` khi thêm hoặc sửa webhook; webhook chưa bật hoặc tài khoản không còn gói hợp lệ sẽ bỏ qua `invoiceMetadata`.
- Mỗi tài khoản ngân hàng chỉ được chọn một webhook để nhận thông tin xuất hóa đơn.
- Chỉ giao dịch tiền vào (`transferType: "IN"`) với số tiền lớn hơn `0` được đưa vào hàng chờ hóa đơn.
- `invoiceOptions.requested` phải là `true`.
- `items` phải có từ 1 đến 50 dòng.
- Nên gửi `itemCode` ở mỗi dòng hàng hóa. Pay2S dùng mã này để khớp với **Quy tắc sản phẩm**; nếu không khớp thì giữ nguyên tên, đơn vị và thuế suất trong response.
- `itemGroupCode` là mã nhóm tùy chọn. Với response webhook, quy tắc có nguồn `webhook` hoặc `any` được áp dụng; quy tắc riêng cửa hàng ưu tiên hơn quy tắc toàn tài khoản, mã sản phẩm ưu tiên hơn mã nhóm.
- `taxRate` nhận một trong các giá trị: `-2`, `-1`, `0`, `3.5`, `5`, `8`, `10`.
- `buyerNotTakingInvoice: true` dùng cho người mua không lấy hóa đơn. Nếu có MST hoặc CCCD, Pay2S vẫn ưu tiên thông tin định danh.
- Pay2S chống tạo trùng theo ID giao dịch ngân hàng; endpoint có thể nhận lại cùng một webhook nhưng không sinh thêm yêu cầu hóa đơn.
- Lỗi riêng của `invoiceMetadata` không làm thay đổi trạng thái tiếp nhận webhook giao dịch. Doanh nghiệp cần kiểm tra màn hình hóa đơn/đối soát để xử lý metadata bị từ chối.

### Điều kiện để Pay2S tiếp nhận yêu cầu hóa đơn

Pay2S chỉ tạo yêu cầu hóa đơn khi đồng thời thỏa các điều kiện sau:

1. Endpoint trả HTTP `2xx`, JSON hợp lệ và `success` bằng `true`.
2. Phản hồi có `invoiceMetadata.invoiceOptions.requested: true`.
3. Webhook nhận giao dịch tiền vào và đã bật **Nhận thông tin xuất hóa đơn từ phản hồi webhook**.
4. Tài khoản Pay2S đang dùng gói Basic trở lên và đã được bật tính năng hóa đơn điện tử.
5. Giao dịch có `transferType: "IN"` và `transferAmount` lớn hơn `0`.
6. `items` có từ 1 đến 50 dòng và từng dòng vượt qua kiểm tra dữ liệu bên dưới.

Nếu chỉ muốn xác nhận đã nhận giao dịch mà không yêu cầu lập hóa đơn, giữ nguyên phản hồi `{ "success": true }` hoặc đặt `requested: false`.

### Chi tiết các tham số phản hồi

#### Đối tượng gốc

| Tham số | Kiểu | Bắt buộc | Mô tả |
| --- | --- | --- | --- |
| `success` | boolean | Có | Phải là `true` để Pay2S ghi nhận endpoint đã xử lý thành công. |
| `invoiceMetadata` | object | Không | Dữ liệu yêu cầu lập hóa đơn. Bỏ trường này nếu giao dịch không cần hóa đơn. |

#### `invoiceMetadata`

Tổng dung lượng JSON của riêng `invoiceMetadata` không được vượt quá 32 KB.

| Tham số | Kiểu | Bắt buộc | Giá trị / giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `invoiceType` | string | Không | `regular`, `vat`, `none` | Nhãn phân loại tương thích, mặc định là `regular`. Loại hóa đơn thực tế vẫn do mẫu mặc định của kết nối quyết định. Nếu không yêu cầu hóa đơn, hãy bỏ `invoiceMetadata` hoặc đặt `requested: false`; không dựa vào `none` để hủy yêu cầu. |
| `invoiceOptions` | object | Có |  | Tùy chọn lập hóa đơn. |
| `customerInfo` | object | Không |  | Thông tin người mua. Có thể để `{}` khi bán cho người tiêu dùng không lấy hóa đơn. |
| `items` | array | Có | 1–50 dòng | Danh sách hàng hóa, dịch vụ. |

#### `invoiceOptions`

| Tham số | Kiểu | Bắt buộc | Giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `requested` | boolean | Có | Phải là `true` | Xác nhận endpoint yêu cầu Pay2S lập hóa đơn cho giao dịch. |
| `buyerNotTakingInvoice` | boolean | Không | Mặc định `false` | Đặt `true` khi bán cho người tiêu dùng không lấy hóa đơn. Nếu có MST hoặc CCCD, Pay2S vẫn ưu tiên dữ liệu định danh đã gửi. |
| `paymentMethod` | string | Không | Tối đa 100 ký tự | Hình thức thanh toán, ví dụ `TM/CK`, `Chuyển khoản`. |
| `note` | string | Không | Tối đa 500 ký tự | Ghi chú nội bộ đi cùng yêu cầu hóa đơn. |

#### `customerInfo`

| Tham số | Kiểu | Bắt buộc | Giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `buyerContactName` | string | Không | 150 ký tự | Họ tên người mua hoặc người liên hệ. |
| `buyerCompanyName` | string | Không | 255 ký tự | Tên doanh nghiệp/đơn vị mua hàng. Để trống khi là khách lẻ. |
| `taxCode` | string | Không | 30 ký tự | Mã số thuế người mua. Không dùng chung trường này để truyền CCCD. |
| `citizenId` | string | Không | 30 ký tự | CCCD hoặc giấy tờ định danh của người mua. |
| `address` | string | Không | 500 ký tự | Địa chỉ người mua. |
| `email` | string | Không | 190 ký tự | Email nhận hóa đơn. |
| `phone` | string | Không | 30 ký tự | Số điện thoại người mua. |

#### Mỗi phần tử trong `items`

| Tham số | Kiểu | Bắt buộc | Giá trị / giới hạn | Mô tả |
| --- | --- | --- | --- | --- |
| `itemCode` | string | Không, nên có | 100 ký tự | Mã sản phẩm ổn định trong hệ thống nguồn. Dùng để khớp Quy tắc sản phẩm. |
| `itemGroupCode` | string | Không | 100 ký tự | Mã nhóm ổn định; dùng làm quy tắc dự phòng khi chưa có quy tắc theo sản phẩm. |
| `externalItemId` | string | Không | 150 ký tự | ID tham chiếu của mặt hàng trong hệ thống nguồn; phục vụ truy vết, không thay thế `itemCode`. |
| `sourceProductName` | string | Không | 500 ký tự | Tên gốc tại hệ thống nguồn. Dùng để ghép tên và đối chiếu quy tắc theo tên sản phẩm. |
| `itemName` | string | Có | 1–500 ký tự | Tên hàng hóa/dịch vụ. Là giá trị đối chiếu dự phòng cho quy tắc theo tên khi thiếu `sourceProductName`; nếu không có quy tắc khớp, Pay2S dùng trực tiếp tên này. |
| `unit` | string | Không | 50 ký tự | Đơn vị tính, ví dụ `Cái`, `Tháng`, `Lần`. |
| `quantity` | number | Có | Lớn hơn `0` | Số lượng. |
| `unitPrice` | number | Có | Từ `0` trở lên | Đơn giá trước thuế/giảm trừ theo nghiệp vụ của mẫu hóa đơn. |
| `taxRate` | number | Có | `-2`, `-1`, `0`, `3.5`, `5`, `8`, `10` | Thuế suất: `-2` = không kê khai, tính nộp thuế; `-1` = không chịu thuế; các giá trị còn lại là phần trăm thuế. |
| `vatReductionEligible` | boolean | Không | Mặc định `false` | Mặt hàng thuộc chính sách giảm thuế GTGT theo quy định hiện hành. Chỉ bật khi có căn cứ áp dụng. |
| `directVatRate` | number | Có khi `vatReductionEligible: true` | `1`, `2`, `3`, `5` | Tỷ lệ % để tính thuế GTGT theo phương pháp tỷ lệ trên doanh thu. |

> Không dùng chuỗi như `"5%"` cho `taxRate` hoặc `directVatRate`; hãy truyền số `5`. Không gửi giá trị tiền đã định dạng như `"50.000 đ"`; hãy truyền số `50000`.

### Đấu nối với Quy tắc sản phẩm

Quy tắc sản phẩm giúp dữ liệu từ website, POS, WHMCS, WooCommerce hoặc ứng dụng riêng được chuẩn hóa trước khi đưa lên hóa đơn. Quy tắc có thể thay tên hàng, mã hàng, đơn vị tính, thuế suất và cấu hình giảm thuế mà không buộc hệ thống nguồn phải biết định dạng riêng của từng nhà cung cấp hóa đơn.

#### Bước 1: Chọn mã đối chiếu ổn định

- `itemCode` phải đại diện cho **loại sản phẩm**, ví dụ `POS_DRINK_001` hoặc `API_PRODUCT_HOSTING_BASIC`.
- `itemGroupCode` đại diện cho **nhóm sản phẩm**, ví dụ `POS_DRINK` hoặc `API_GROUP_HOSTING`.
- Không dùng Order ID, ID giao dịch ngân hàng hoặc ID riêng của từng dòng đơn hàng vì các mã này thay đổi sau mỗi lần bán và sẽ không tái sử dụng được quy tắc.
- Cùng một sản phẩm phải luôn trả cùng `itemCode`, kể cả tên hiển thị hoặc giá bán thay đổi.

#### Bước 2: Tạo quy tắc trong Pay2S

Mở **Hóa đơn điện tử → kết nối nhà cung cấp → Hàng hóa / Quy tắc sản phẩm**, sau đó:

1. Chọn **Nguồn tích hợp** là `Webhook` cho dữ liệu phản hồi webhook; chọn `Mọi nguồn` nếu muốn dùng chung quy tắc cho các luồng khác.
2. Chọn **Mã sản phẩm** để đối chiếu `itemCode`, **Mã nhóm sản phẩm** để đối chiếu `itemGroupCode`, hoặc **Tên sản phẩm** khi hệ thống nguồn không có mã ổn định.
3. Với mã, nhập chính xác mã nguồn gửi lên. Với tên, Pay2S khớp toàn bộ sau khi bỏ khoảng trắng thừa và không phân biệt chữ hoa/thường; tên có dấu và không dấu vẫn là hai giá trị khác nhau.
4. Chọn phạm vi **toàn tài khoản** hoặc một **cửa hàng cụ thể**.
5. Khai báo tên, mã, đơn vị, thuế suất và chính sách giảm thuế muốn hiển thị trên hóa đơn.
6. Lưu quy tắc rồi gửi một giao dịch thử; kiểm tra kết quả tại **Hàng chờ** trước khi bật tự phát hành.

#### Thứ tự ưu tiên khi nhiều quy tắc cùng khớp

Pay2S chọn đúng một quy tắc theo thứ tự:

1. Quy tắc của cửa hàng hiện tại ưu tiên hơn quy tắc toàn tài khoản.
2. Quy tắc nguồn `webhook` ưu tiên hơn nguồn `any` (Mọi nguồn).
3. Trong cùng phạm vi và nguồn: quy tắc theo `itemCode` ưu tiên hơn `itemGroupCode`, và `itemGroupCode` ưu tiên hơn tên sản phẩm.
4. Nếu vẫn bằng nhau, quy tắc được tạo sau cùng (ID lớn hơn) được chọn.

Ví dụ endpoint trả:

```json
{
  "itemCode": "POS_DRINK_001",
  "itemGroupCode": "POS_DRINK",
  "itemName": "nuoc ngot lon",
  "unit": "item",
  "quantity": 2,
  "unitPrice": 15000,
  "taxRate": 0
}
```

Và Pay2S có quy tắc `POS_DRINK_001` đổi thành **Nước ngọt lon**, đơn vị **Lon**, thuế suất **8%**, hóa đơn sẽ dùng dữ liệu đã chuẩn hóa từ quy tắc. Số lượng và đơn giá vẫn lấy từ giao dịch hiện tại. Nếu không có quy tắc nào khớp, Pay2S giữ `itemName`, `unit` và `taxRate` trong response; yêu cầu không bị từ chối chỉ vì thiếu quy tắc.

### Quy trình kiểm thử khuyến nghị

1. Tạo kết nối nhà cung cấp ở môi trường thử nghiệm và kiểm tra kết nối thành công.
2. Đồng bộ mẫu hóa đơn, chọn mẫu mặc định và gán đúng cửa hàng.
3. Bật **Nhận thông tin xuất hóa đơn từ phản hồi webhook** cho webhook nhận tiền vào.
4. Cho endpoint trả một `invoiceMetadata` có một mặt hàng và tổng `quantity × unitPrice` bằng số tiền giao dịch.
5. Kiểm tra bản ghi xuất hiện ở **Hàng chờ**, xem thông tin người mua và hàng hóa đã được Quy tắc sản phẩm chuẩn hóa đúng chưa.
6. Tạo bản nháp, kiểm tra PDF và tổng tiền; sau khi ổn định mới bật tự tạo nháp hoặc tự phát hành.
7. Theo dõi **Đối soát** và **Nhật ký** khi có chênh lệch hoặc lỗi nhà cung cấp.

### Các lỗi thường gặp

| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
| --- | --- | --- |
| Giao dịch nhận thành công nhưng không có yêu cầu hóa đơn | Chưa bật tùy chọn trên webhook, `requested` không phải `true`, tài khoản dưới gói Basic hoặc giao dịch là tiền ra | Kiểm tra lại 6 điều kiện tiếp nhận ở đầu mục này. |
| Quy tắc sản phẩm không được áp dụng | Sai nguồn, sai phạm vi cửa hàng, nhầm `itemCode` với `itemGroupCode`, mã thay đổi theo từng đơn hoặc tên không khớp toàn bộ | Dùng mã ổn định; nếu khớp theo tên, kiểm tra `sourceProductName`/`itemName` và đúng dấu tiếng Việt. |
| Metadata bị từ chối | Thiếu `itemName`, `items` rỗng/quá 50 dòng, số lượng/đơn giá sai kiểu hoặc thuế suất ngoài danh sách | So sánh response với bảng tham số và xem lỗi tại Hàng chờ/Nhật ký. |
| Tổng hóa đơn không khớp giao dịch | Tổng các dòng hàng không bằng số tiền chuyển khoản hoặc quy tắc làm thay đổi thuế/giảm trừ | Kiểm tra lại giá, thuế và chính sách giảm thuế trước khi phát hành. |
| Có webhook gửi lại nhiều lần | Timeout hoặc cơ chế retry của Pay2S | Giữ endpoint idempotent; Pay2S chống tạo trùng hóa đơn theo ID giao dịch. |

