Developer

📚 Tài liệu kỹ thuật Webhook

✧ Mở bằng AI

Sao chép tài liệu, mở AI rồi dán vào cuộc trò chuyện.

ChatGPTClaudePerplexityGrok
≡ Xem Markdown

🔄 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
{
  "Content-Type": "application/json",
  "Authorization": "Bearer <YOUR_TOKEN_WEBHOOK>"
}

Body (POST Parameters)

{
  "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 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

{
  "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

{
  "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

{
  "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.

{
  "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

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.

Response từ Endpoint của bạn

{
  "success": true
}

🔁 Cơ chế Retry

Worker gửi trực tiếp và worker retry có lịch riêng; timeout, khoảng cách retry và số lần thử phụ thuộc cấu hình worker. Không suy ra lịch nhận chính xác chỉ từ số lần thử.

Receiver nên trả HTTP 200 và JSON { "success": true } sau khi đã lưu giao dịch hoặc đưa vào hàng đợi bền vững. Worker giao dịch hiện chấp nhận HTTP 2xx đồng thời body có success đúng; chỉ trả HTTP 200 hoặc chỉ có JSON thành công với HTTP lỗi đều không đủ. Đây không phải điều kiện ACK của IPN thanh toán.


💻 Code mẫu

<?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]);
?>
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'));
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)
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));
    }
}
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)
}
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:

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


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:

{
  "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:

{
  "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 ý:

Đ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

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ả:

{
  "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.

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 giao dịch · Payload & ACK

Đối chiếu mã nguồn backend; giá trị minh họa. Đối chiếu webhooksender.js và immediate-account-webhook.js. paymentCode có khi trích được mã khớp mẫu; extraData chỉ có khi bật dữ liệu bổ sung. Giá trị là minh họa.

POST {webhook_url}

Body:

{
  "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"
      }
    }
  ]
}

HTTP 200. Response:

{
  "success": true
}
Tìm trong 57 trang tài liệu · Esc để đóng