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 200Implement 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 schema3. 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/ipnMonitoring và alerting
Những metric cần theo dõi:
| Metric | Alert 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.