Blog
webhookipnpayment-apibackendsecurity

Webhook & IPN: Xử lý callback thanh toán an toàn và đáng tin cậy

Hướng dẫn implement IPN handler an toàn: idempotency, xác thực chữ ký, retry logic, xử lý lỗi và monitoring — những gì phân biệt hệ thống production với code demo.

TConnect Team 8 tháng 1, 2026 6 min read

IPN là gì và tại sao quan trọng hơn return_url?

IPN (Instant Payment Notification) là cơ chế server-to-server: sau khi giao dịch hoàn tất, TConnect gọi trực tiếp vào backend của bạn để thông báo kết quả — bất kể người dùng có quay lại website hay không.

return_url chỉ là redirect trình duyệt. Người dùng có thể:

  • Đóng tab ngay sau khi thanh toán
  • Mạng bị ngắt khi đang redirect
  • Bấm nút Back trên trình duyệt

Nếu bạn mark order paid tại return_url, hệ thống sẽ bỏ sót các trường hợp trên. Luôn dùng IPN để xác nhận thanh toán.


Luồng IPN chuẩn

[Khách thanh toán tại cổng TConnect]


   [TConnect xác nhận với ngân hàng]

     ┌────────┴────────┐
     │                 │
[IPN POST]      [Redirect return_url]
 server-to-server    trình duyệt


[Backend của bạn]

  Verify → Process → Return 200

Implement IPN Handler chuẩn production

// IPN endpoint: POST /webhook/tconnect/ipn
function ipn_handler(request):
 
    // Parse request body
    body = parse_json(request)
    if parse fails:
        return error 400 "Invalid JSON"
 
    // 1. Decrypt payload
    txn = decrypt_aes(body.data, AES_KEY)
    if decrypt fails:
        log error "IPN decrypt failed"
        return error 400 "Decrypt failed"
 
    request_id = txn.request_id
    order_id   = txn.order_id
    status     = txn.status
    amount     = txn.amount
 
    log info "IPN received: order={order_id} status={status} amount={amount}"
 
    // 2. Idempotency — dùng request_id làm unique key
    cache_key = "ipn:processed:{request_id}"
    if cache.get(cache_key) exists:
        log info "IPN duplicate skipped: request_id={request_id}"
        return { status: "ok" }   // Trả 200 để TConnect không retry tiếp
 
    // 3. Xác thực order tồn tại
    order = db.get_order(order_id)
    if order not found:
        log warning "IPN for unknown order: {order_id}"
        return { status: "ok" }   // Vẫn trả 200
 
    // 4. Xác thực số tiền
    if amount != order.expected_amount:
        log warning "IPN amount mismatch: expected={order.expected_amount}, got={amount}"
        db.flag_order_for_review(order_id, reason="amount_mismatch")
        return { status: "ok" }
 
    // 5. Xử lý theo trạng thái
    if status == "SUCCESS":
        db.mark_order_paid(order_id, txn)
        send_confirmation_email(order)
    else if status == "FAILED":
        db.mark_order_failed(order_id, txn.error_code)
    else if status == "PENDING":
        log info "IPN pending for order {order_id} — waiting for final status"
        return { status: "ok" }
 
    // 6. Mark đã xử lý — TTL 24h để tránh memory leak
    cache.set(cache_key, ttl=86400)
 
    // 7. Bắt buộc trả 200
    return { status: "ok" }

Idempotency: Tại sao bắt buộc?

TConnect retry IPN trong các trường hợp:

  • Backend của bạn trả HTTP 5xx
  • Connection timeout
  • Không nhận được response

Nếu không có idempotency check, một giao dịch có thể được xử lý 2–3 lần:

IPN lần 1 → Backend xử lý → Trả 200 nhưng timeout
IPN retry 1 → Backend xử lý LẦN 2 ← LỖI: charge 2 lần!

Giải pháp: Dùng request_id (unique mỗi IPN) làm key trong Redis/database:

// Trước khi xử lý
if cache.get("ipn:{txn.request_id}") exists:
    return { status: "ok" }   // Skip
 
// Sau khi xử lý thành công
cache.set("ipn:{txn.request_id}", ttl=86400)

Xử lý IPN bị delay hoặc không đến

Mạng không phải lúc nào cũng hoàn hảo. Implement fallback:

// Cronjob chạy mỗi 15 phút
function reconcile_pending_orders():
    // Lấy các order pending quá 10 phút
    pending_orders = db.get_pending_orders(older_than_minutes=10)
 
    for each order in pending_orders:
        try:
            // Query TConnect API để lấy trạng thái thực
            status = tconnect.check_payment_status(order.id)
 
            if status == "SUCCESS":
                db.mark_order_paid(order.id)
            else if status == "FAILED":
                db.mark_order_failed(order.id)
            else if status == "EXPIRED":
                db.cancel_order(order.id)
 
        on error:
            log error "Reconcile failed for order {order.id}"

Bảo mật IPN endpoint

1. Whitelist IP của TConnect

TCONNECT_IPS = ["103.x.x.x", "118.x.x.x"]   // Lấy từ tài liệu TConnect
 
// HTTP middleware — chạy trước mọi request
function verify_ip_middleware(request, next):
    if request.path starts with "/webhook/tconnect":
        client_ip = request.client_ip
        if client_ip not in TCONNECT_IPS:
            return response 403 Forbidden
    return next(request)

2. Không expose endpoint IPN trên docs/swagger

Thêm vào OpenAPI config:

// Tắt docs/swagger hoặc exclude route IPN khỏi API schema
configure app:
    disable_docs = true   // Hoặc exclude "/webhook/tconnect/ipn" khỏi schema

3. Rate limiting

// Áp dụng rate limiting cho IPN endpoint
// Giới hạn: 60 request/phút theo IP nguồn
// TConnect không gửi quá nhiều IPN trong 1 phút
apply rate_limit(60 per minute, key=client_ip) to POST /webhook/tconnect/ipn

Monitoring và alerting

Những metric cần theo dõi:

MetricAlert khi
IPN processing time> 2 giây
IPN failure rate> 1%
Duplicate IPN rate> 5% (có thể đang retry nhiều)
Pending orders quá 30 phút> 0
AES decrypt error> 0
// Metrics cần theo dõi (ví dụ: Prometheus / StatsD)
counter   ipn_total(status)       // Tổng IPN nhận được, nhóm theo status
histogram ipn_duration_seconds    // Thời gian xử lý mỗi IPN
counter   ipn_duplicates_total    // Số IPN trùng đã bỏ qua
 
// Trong IPN handler
function ipn_handler(request):
    start_timer()
    // ... xử lý ...
    record ipn_duration_seconds = elapsed_time()
    increment ipn_total(status = txn.status)

Kết luận

IPN handler tốt không chỉ là "nhận và xử lý" — đó là hệ thống đáng tin cậy dù mạng chập chờn, dù TConnect retry nhiều lần, dù server restart giữa chừng. Đầu tư vào idempotency và reconciliation ngay từ đầu sẽ tiết kiệm rất nhiều debugging sau này.

Bắt đầu tích hợp ngay

Sandbox miễn phí · Tài liệu API đầy đủ · Hỗ trợ kỹ thuật