Skip to content

📚 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
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ướcThời gianHành động
1️⃣ Gửi ngayLập tứcGửi dữ liệu đến endpoint
2️⃣ Retry 1+60 giâyGửi lại nếu không nhận 200 OK
3️⃣ Retry 2-5+60 giây mỗi lầnTiếp tục retry (tối đa 5 lần)
4️⃣ DừngSau lần 6Ngừ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

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

  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": "[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 đơ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"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ểuBắt buộcMô tả
successbooleanPhải là true để Pay2S ghi nhận endpoint đã xử lý thành công.
invoiceMetadataobjectKhôngDữ 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ểuBắt buộcGiá trị / giới hạnMô tả
invoiceTypestringKhôngregular, vat, noneNhã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.
invoiceOptionsobjectTùy chọn lập hóa đơn.
customerInfoobjectKhôngThông tin người mua. Có thể để {} khi bán cho người tiêu dùng không lấy hóa đơn.
itemsarray1–50 dòngDanh sách hàng hóa, dịch vụ.

invoiceOptions

Tham sốKiểuBắt buộcGiới hạnMô tả
requestedbooleanPhải là trueXác nhận endpoint yêu cầu Pay2S lập hóa đơn cho giao dịch.
buyerNotTakingInvoicebooleanKhôngMặ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.
paymentMethodstringKhôngTối đa 100 ký tựHình thức thanh toán, ví dụ TM/CK, Chuyển khoản.
notestringKhôngTối đa 500 ký tựGhi chú nội bộ đi cùng yêu cầu hóa đơn.

customerInfo

Tham sốKiểuBắt buộcGiới hạnMô tả
buyerContactNamestringKhông150 ký tựHọ tên người mua hoặc người liên hệ.
buyerCompanyNamestringKhông255 ký tựTên doanh nghiệp/đơn vị mua hàng. Để trống khi là khách lẻ.
taxCodestringKhông30 ký tựMã số thuế người mua. Không dùng chung trường này để truyền CCCD.
citizenIdstringKhông30 ký tựCCCD hoặc giấy tờ định danh của người mua.
addressstringKhông500 ký tựĐịa chỉ người mua.
emailstringKhông190 ký tựEmail nhận hóa đơn.
phonestringKhông30 ký tựSố điện thoại người mua.

Mỗi phần tử trong items

Tham sốKiểuBắt buộcGiá trị / giới hạnMô tả
itemCodestringKhô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.
itemGroupCodestringKhông100 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.
externalItemIdstringKhông150 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.
sourceProductNamestringKhông500 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.
itemNamestring1–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.
unitstringKhông50 ký tựĐơn vị tính, ví dụ Cái, Tháng, Lần.
quantitynumberLớn hơn 0Số lượng.
unitPricenumberTừ 0 trở lênĐơn giá trước thuế/giảm trừ theo nghiệp vụ của mẫu hóa đơn.
taxRatenumber-2, -1, 0, 3.5, 5, 8, 10Thuế 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ế.
vatReductionEligiblebooleanKhôngMặc định falseMặ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.
directVatRatenumberCó khi vatReductionEligible: true1, 2, 3, 5Tỷ 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ợpWebhook 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 Khớp theo sản phẩm để đối chiếu itemCode, hoặc Khớp theo nhóm để đối chiếu itemGroupCode.
  3. 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.
  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. Quy tắc theo itemCode ưu tiên hơn quy tắc theo itemGroupCode.
  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, unittaxRate 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átNhậ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ượngNguyên nhân thường gặpCách xử lý
Giao dịch nhận thành công nhưng không có yêu cầu hóa đơnChư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 raKiể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ụngSai nguồn, sai phạm vi cửa hàng, nhầm itemCode với itemGroupCode hoặc mã thay đổi theo từng đơnDùng mã ổn định và kiểm tra đúng thứ tự ưu tiên quy tắc.
Metadata bị từ chốiThiế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áchSo 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ịchTổ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ầnTimeout hoặc cơ chế retry của Pay2SGiữ endpoint idempotent; Pay2S chống tạo trùng hóa đơn theo ID giao dịch.