Appearance
📚 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
POSTHeader
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:
- HTTP status:
200 OKhoặc - 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
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
const express = require('express');
const app = express();
app.use(express.json());
const SECRET_KEY = 'your_expected_token_here';
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
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = 'your_expected_token_here'
@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
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_expected_token_here";
@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
package main
import (
"encoding/json"
"log"
"net/http"
"strings"
)
const SECRET_KEY = "your_expected_token_here"
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
require 'sinatra'
require 'json'
SECRET_KEY = 'your_expected_token_here'
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
- Xác thực ngay: Kiểm tra token trước khi xử lý
- Validate dữ liệu: Kiểm tra
transactionsarray trước khi loop - Response nhanh: Trả về 200 ngay, offload xử lý vào queue/background job
- Idempotent: Xử lý webhook nhiều lần mà không bị lỗi (kiểm tra ID trước insert)
- Logging: Log tất cả webhook request/response để debug
- Error handling: Không trả về 200 nếu xử lý thất bại
- Timeout: Đặt timeout vì Pay2S sẽ retry sau 60 giây
- Database transaction: Dùng DB transaction để tránh data inconsistency
- Monitoring: Alert nếu webhook failed nhiều lần
- 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": "[email protected]",
"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 đơnkhi 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ỏ quainvoiceMetadata. - 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ơn0được đưa vào hàng chờ hóa đơn. invoiceOptions.requestedphải làtrue.itemsphả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. itemGroupCodelà mã nhóm tùy chọn. Với response webhook, quy tắc có nguồnwebhookhoặcanyđượ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.taxRatenhận một trong các giá trị:-2,-1,0,3.5,5,8,10.buyerNotTakingInvoice: truedù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
invoiceMetadatakhô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:
- Endpoint trả HTTP
2xx, JSON hợp lệ vàsuccessbằngtrue. - Phản hồi có
invoiceMetadata.invoiceOptions.requested: true. - 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.
- 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ử.
- Giao dịch có
transferType: "IN"vàtransferAmountlớn hơn0. itemscó 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, hữu ích khi Quy tắc sản phẩm chọn ghép tên nguồn. |
itemName | string | Có | 1–500 ký tự | Tên hàng hóa/dịch vụ. 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%"chotaxRatehoặcdirectVatRate; 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
itemCodephải đại diện cho loại sản phẩm, ví dụPOS_DRINK_001hoặcAPI_PRODUCT_HOSTING_BASIC.itemGroupCodeđại diện cho nhóm sản phẩm, ví dụPOS_DRINKhoặcAPI_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 đó:
- Chọn Nguồn tích hợp là
Webhookcho dữ liệu phản hồi webhook; chọnMọi nguồnnếu muốn dùng chung quy tắc cho các luồng khác. - Chọn Khớp theo sản phẩm để đối chiếu
itemCode, hoặc Khớp theo nhóm để đối chiếuitemGroupCode. - Nhập chính xác mã từ hệ thống nguồn, có phân biệt nội dung ký tự sau khi loại bỏ khoảng trắng thừa ở hai đầu.
- Chọn phạm vi toàn tài khoản hoặc một cửa hàng cụ thể.
- 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.
- 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ự:
- 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.
- Quy tắc nguồn
webhookưu tiên hơn nguồnany(Mọi nguồn). - Quy tắc theo
itemCodeưu tiên hơn quy tắc theoitemGroupCode. - 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ị
- 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.
- Đồng bộ mẫu hóa đơn, chọn mẫu mặc định và gán đúng cửa hàng.
- 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.
- Cho endpoint trả một
invoiceMetadatacó một mặt hàng và tổngquantity × unitPricebằng số tiền giao dịch. - 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.
- 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.
- 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 hoặc mã thay đổi theo từng đơn | Dùng mã ổn định và kiểm tra đúng thứ tự ưu tiên quy tắc. |
| 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. |
