Thu tiền thật mà không bao giờ chạm vào một chữ số thẻ nào: API của nhà cung cấp, webhook của nhà cung cấp, và luồng bất đồng bộ (async) lật trạng thái app của bạn. (Sepay là một ví dụ điển hình của nhà cung cấp loại này — dạng thì giống nhau ở mọi nơi.)
← / → để chuyển slide · chuyển EN/VI bên dưới
Khoảnh khắc app của bạn thu tiền, bạn đối mặt với mạng thẻ, 3-D Secure, quy tắc chống gian lận và hàng chục phương thức thanh toán địa phương. Đó là cả một ngành — hãy thuê, đừng tự xây.
Xử lý thanh toán là ngành có giấy phép, có quy định (quy tắc Visa/Mastercard, ngân hàng địa phương, ví điện tử). Bạn 'thuê' nó từ nhà cung cấp thanh toán (gateway) — Sepay, Stripe, Paystack… đều bán cái này.
Khách nhập số thẻ vào trang của nhà cung cấp, không phải trang của bạn. Server của bạn không bao giờ thấy hay lưu số thẻ — chỉ cần một ranh giới đó là bạn ra khỏi phạm vi PCI.
Một API để bạn gọi (tạo thanh toán, tra trạng thái) và một webhook để nó gọi lại (thanh toán thành công / thất bại). Toàn bộ tích hợp xoay quanh đúng hai thứ đó.
Thanh toán mở khóa cái gì, giá bao nhiêu, bản ghi đơn hàng, chịu được các lần retry. Đó là code của bạn — và là tất cả những gì bạn viết trong chủ đề này.
Chỉ bạn và nhà cung cấp viết code. Khách chỉ việc trả tiền; ngân hàng chỉ việc chuyển tiền.
Chọn gói, trả tiền trên trang của nhà cung cấp. Thấy các lựa chọn thẻ hoặc ví điện tử.
Biết giá và tiền mở khóa cái gì. Gọi API của nhà cung cấp. Lắng nghe webhook của họ.
ví dụ Sepay — một gateway. Dựng trang checkout, giữ dữ liệu thẻ, báo lại qua webhook.
Mạng thẻ, Vietcombank, MoMo, ZaloPay… Phần có giấy phép của ngành. Không ai tự viết thứ này.
①–③ đồng bộ (vài giây). ④ bất đồng bộ (vài phút) — và ④ là thứ bạn phải xây để tồn tại.
Bạn không bao giờ thấy tiền. Bạn chỉ thấy webhook. Xây app để tin vào webhook — và để xử lý nó đúng một lần.
Nhà cung cấp thanh toán là trung gian có giấy phép giữa bạn và tiền. Học dạng chuẩn một lần; Sepay (phổ biến ở VN: thẻ + ví điện tử) chỉ là một trường hợp cụ thể của nó.
Mọi gateway trên thế giới đều bán gói này. Tên endpoint thay đổi; gói thì không.
Một nhóm nhỏ endpoint HTTPS: tạo thanh toán, tra trạng thái, (sau này) hủy hoặc hoàn. Bạn gọi bằng secret key, nhận về JSON.
Trang checkout được host sẵn (form thẻ hoặc chọn ví điện tử) do nhà cung cấp dựng. Dữ liệu thẻ đi thẳng đến nhà cung cấp — ngang qua app của bạn, không bao giờ đi xuyên qua nó.
Một URL bạn đăng ký. Khi có chuyện xảy ra (đã trả, thất bại, hết hạn), nhà cung cấp POST một sự kiện JSON đến đó. Đây là nửa async của mọi thứ.
Ranh giới giữa hai cột chính là toàn bộ kiến trúc. Vượt qua nó (lưu số thẻ, tự gọi API ngân hàng) là bạn đã tự xây lại ngân hàng.
Giữ dữ liệu thẻ, nói chuyện với ngân hàng và ví điện tử, kiểm tra chống gian lận và 3-D Secure, thu tiền, báo kết quả.
Sản phẩm của họ là độ tin cậy ở quy mô lớn: retry, khiếu nại, uptime. Có giấy phép, có quy định — hãy thuê.
Biết thanh toán là cho cái gì (đơn hàng của bạn), báo giá cho nhà cung cấp, lắng nghe webhook, lật trạng thái app — mở khóa sản phẩm.
Toàn bộ logic kinh doanh — của bạn. Bạn xây state machine, không phải ngân hàng.
PCI DSS là bộ quy định tuân thủ dành cho bất kỳ ai lưu hoặc truyền dữ liệu thẻ. Trang checkout host sẵn của nhà cung cấp gánh thay bạn phần đó — miễn phí.
Không trong cơ sở dữ liệu, không trong log, không trong code. Nếu số thẻ chạm server của bạn, bạn vừa nhận thêm một vấn đề tuân thủ.
Họ là hệ thống nằm trong phạm vi PCI. Server của bạn thì không. 'Dữ liệu thẻ không bao giờ vào app của tôi' đáng giá hơn mọi tính năng bạn ship.
Những gì bạn thực sự giữ: API key của nhà cung cấp (server-side, env var) và đơn hàng của chính bạn. Phần còn lại nằm bên kia ranh giới.
Toàn bộ vũ điệu trong một dòng: khách chọn gói, app của bạn nhờ nhà cung cấp thu tiền, và webhook của họ báo cho bạn là đã xong.
Nhịp 1–3 đồng bộ, chỉ vài giây. Nhịp 4–5 bất đồng bộ, mất vài phút — nhịp 4 là thứ bạn phải xây để tồn tại.
Việc mở khóa (5) được kích hoạt bởi 4 — không bao giờ bởi redirect về trình duyệt của khách.
Một POST HTTPS duy nhất từ app của bạn sang nhà cung cấp. Response là URL checkout — khách sang đó để trả tiền thật.
# your app: customer clicked "Pay 200,000đ / month"
import os
import requests
r = requests.post(
"https://api.sepay.example/v1/payments",
headers={"Authorization": "Bearer " + os.environ["SEPAY_API_KEY"]},
json={
"amount": 200000, # VND, integer
"currency": "VND",
"order_ref": "order_8123", # YOUR id — you'll match on it later
"webhook_url": "https://app.example.com/webhooks/pay",
},
)
checkout = r.json() # -> {"url": "https://pay.sepay.example/p/a1b2c3"}
Không thấy dữ liệu thẻ đâu. Bạn gửi thứ bạn bán và nơi để báo lại. Nhà cung cấp trả về URL checkout — chứ không phải 'đã trả'.
Lỗi phổ biến nhất của người mới: thấy HTTP 200 từ 'tạo thanh toán' và mở khóa sản phẩm luôn. 200 nghĩa là 'đã tạo trang checkout', không phải 'khách đã trả'.
Nhà cung cấp chấp nhận request và dựng trang checkout. Khách thậm chí chưa thấy nó. Tính đến giờ, tiền đã chuyển: 0đ.
Khách có thể bỏ ngang, trượt 3-D Secure, hoặc đóng tab — lệnh tạo của bạn vẫn trả 200 OK.
Webhook — vài phút sau, bất đồng bộ. payment.succeeded (hoặc failed / expired) là tín hiệu duy nhất rằng tiền thực sự đã chuyển.
Quy tắc: API call tạo ra khả năng thanh toán; webhook xác nhận sự kiện đã xảy ra.
Thứ nhà cung cấp thực sự trả về khi bạn tạo thanh toán: một 200, và một URL checkout. Đọc kỹ mã trạng thái — nó ghi 'created' (đã tạo), không phải 'paid' (đã trả).
API là bạn gọi một URL. Webhook là chiều ngược: nhà cung cấp gọi URL CỦA BẠN, bất cứ khi nào họ muốn, kèm một gói JSON.
Không có phép thuật: nó là một HTTPS POST đến một route của bạn — giống hệt form submission, chỉ khác người gửi là nhà cung cấp và thời điểm do họ quyết.
Khi tích hợp, bạn đăng ký https://app.example.com/webhooks/pay. Từ đó nhà cung cấp có thể POST đến đó. Nó phải công khai (https) — internet phải với tới được.
Mỗi POST là một sự kiện: chuyện gì xảy ra (type), thanh toán nào (id), số tiền, và order_ref của bạn. Nhỏ, có cấu trúc, máy đọc được.
Request đến khi thế giới thực muốn: 30 giây hay 30 phút sau. Server của bạn phải up và sẵn sàng ở bất kỳ thời điểm nào.
Ai tìm ra URL của bạn cũng có thể POST đến đó. Nên xác minh chữ ký trước, rồi mới hành động. Webhook là nguồn sự thật — nhưng bạn kiểm tra ngay tại cửa.
Một mẫu URL, hai chiều. Phần lớn sự nhầm lẫn trong thanh toán đến từ quên mình đang ở phía nào.
| Khía cạnh | API | Webhook |
|---|---|---|
| Ai mở đầu | Bạn — app của bạn gọi | Nhà cung cấp — họ gọi bạn |
| Khi nào | Theo hành động của user, tức thì | Khi thế giới thực muốn — vài giây đến vài phút sau |
| Mang theo gì | Request của bạn: số tiền, tiền tệ, order_ref | Sự kiện của họ: chuyện gì xảy ra, số tiền, ref của bạn |
| Bạn xây gì | Một lời gọi requests (vài dòng) | Một route lắng nghe: POST /webhooks/pay |
| Tư duy | "Hỏi", rồi đọc câu trả lời | "Báo" — trả 200 nhanh, đợi retry, giữ idempotent |
Giữa 'khách bấm trả tiền' và 'server của bạn biết chuyện đó' là những con người thật và những ngân hàng thật. Thiết kế cho khoảng trống, chứ không phải đường đi thuận lợi.
Bên trái webhook: request/response. Bên phải webhook: thế giới vận động, bạn phản ứng.
Header request còn mang chữ ký (ví dụ X-Signature) — bằng chứng nó thực sự đến từ nhà cung cấp. Body mang order_ref của chính bạn — bằng chứng đó là đơn CỦA BẠN.
{
"event": "payment.succeeded",
"id": "evt_9f8e7d6c",
"created_at": "2026-10-27T03:14:09Z",
"payment": {
"id": "pay_a1b2c3",
"amount": 200000,
"currency": "VND",
"order_ref": "order_8123",
"method": "card"
}
}
Mỗi trường tồn tại để handler của bạn có thể xác minh, ghép, và hành động — hoặc an toàn bỏ qua.
Năm việc: xác minh, tìm, đối chiếu, đổi trạng thái, xác nhận. Việc mở khóa nằm ở đây — không phải ở redirect về trình duyệt, thứ mà khách có thể bỏ qua.
@app.post("/webhooks/pay")
def webhook():
if not verify_signature(request): # 1. is it really the provider?
return "not you", 401
data = request.json
order = Order.get(data["payment"]["order_ref"]) # 2. find YOUR order
if order is None or order.total != data["payment"]["amount"]:
return "mismatch", 400 # 3. sanity-check the match
mark_paid(order) # 4. flip state: unlock plan
return "ok", 200 # 5. fast 200 = "I got it"
Ngắn có chủ đích: kiểm tra, đổi trạng thái, rồi rút lui. Việc nặng (email, cấp quyền) giao cho job nền.
Tài liệu của mọi nhà cung cấp đều nói cùng bốn điều này, bằng từ khác. Phá một trong số đó là retry sẽ chất đống.
Nếu bạn chậm (hoặc trả 500), nhà cung cấp cho là giao thất bại và sẽ retry. Xác nhận trước, làm việc nặng sau.
Retry là thiết kế, không phải trạng thái lỗi. Cùng sự kiện (cùng id) được POST lại sau timeout/lỗi, với khoảng cách tăng dần.
Header chữ ký chứng minh nguồn gốc. Không kiểm tra nó, một người lạ biết URL của bạn có thể 'trả tiền' thay user của bạn — miễn phí.
Trình duyệt của khách có thể không bao giờ được redirect về. Webhook nói succeeded nghĩa là succeeded — dù tab của user vẫn hiện spinner.
Đây không phải lỗi — hệ thống đang hoạt động đúng thiết kế. Cùng một sự kiện, giao hai lần, mà cơ sở dữ liệu của bạn vẫn chỉ hiện đúng một lần mở khóa.
Webhook không biết gì về kinh doanh của bạn. Việc của bạn là dịch 'thanh toán X thành công' thành 'đơn Y đã trả' — một cách cẩn thận.
Khi tạo thanh toán bạn gắn order_ref (id DB của bạn). Webhook trả lại nó. Đó là khóa ghép giữa thế giới của họ và của bạn.
Id pay_... là của nhà cung cấp; order_ref là của bạn. Webhook đến với ref bạn không có? Log lại và báo động — đừng bao giờ đoán.
Webhook nói thanh toán nào thành công; DB nói đơn đó đáng bao nhiêu. Số tiền không khớp → đừng mở khóa, kiểm tra. Lớp chống gian lận rẻ nhất bạn từng mua.
Payload thô + event id là hồ sơ kiểm toán. Khi 'khách nói họ trả tiền rồi mà plan vẫn khóa', log trả lời trong vài giây.
Hai điều tách biệt 'demo thu tiền của bạn bè' khỏi 'production không bao giờ thu nhầm tiền người lạ': idempotency và secret.
Webhook sẽ đến hai lần: retry sau timeout, gửi trùng, hoặc chính bạn curl khi test. Handler phải an toàn để chạy bao nhiêu lần cũng được.
Không có khóa: webhook thứ nhất mở khóa plan; webhook thứ hai (cùng sự kiện) chạy lại handler. Nếu nó còn tạo hóa đơn, cộng credit hay gửi email biên lai — khách thấy hai cái cho mỗi thứ.
Nếu đơn đã trả, trả 200 và không làm gì. 'Kiểm tra trạng thái, rồi lật' là toàn bộ mẫu — một bước chuyển chỉ áp dụng đúng một lần.
Bảng processed_events (event id → đã xử lý). Id đã có? 200 và thoát. Chạy được cả khi một đơn hợp lệ trả tiền nhiều lần.
Handler nhận sự kiện trùng mà trả 200 là hành vi lịch sự, đúng đắn. Nhà cung cấp chỉ ngừng retry khi nghe thấy 2xx.
Hai dòng làm trọn việc: 'đã trả chưa?' và 'đã thấy sự kiện này chưa?'. Mọi thứ phía sau chỉ chạy tối đa một lần cho mỗi đơn.
@app.post("/webhooks/pay")
def webhook():
if not verify_signature(request):
return "not you", 401
data = request.json
if Event.seen(data["id"]): # already processed this event
return "ok", 200
order = Order.get(data["payment"]["order_ref"])
if order and order.status == "paid": # already unlocked this order
return "ok", 200
mark_paid(order)
Event.remember(data["id"])
return "ok", 200
Giờ retry, gửi trùng và cả lệnh curl test của bạn đều vô hại — hiệu ứng là 'tối đa một lần' bất kể request đến bao nhiêu lần.
Bạn chỉ giữ đúng hai thứ nhạy cảm: API key của nhà cung cấp và việc tồn tại URL webhook của bạn. Đối xử với mỗi thứ cho đúng.
Env var trên server, không bao giờ trong repo, không trong code frontend hay config công khai. Ai giữ được nó có thể tạo (và thường là hoàn) thanh toán.
Ai có nó cũng có thể POST — đó là lý do kiểm tra chữ ký không được bỏ qua. URL công khai là tính năng, không phải rò rỉ.
Key nằm trong dashboard, nơi bạn tạo lại được trong vài giây. Nghi ngờ rò rỉ? Đổi ngay, rồi kiểm tra hoạt động gần đây. Nhanh tay luôn hơn điều tra sau.
Cùng một mẫu ở mọi nơi triển khai: file .env bị git bỏ qua, và code đọc nó khi chạy. Key chỉ xuất hiện đúng một nơi — trong môi trường của server.
# .env — git-ignored, lives only on the server
SEPAY_API_KEY=sk_live_9f8e7d6c5b4a3210
SEPAY_WEBHOOK_SECRET=whsec_1a2b3c4d5e6f
# app.py reads it at runtime — never hard-coded
API_KEY = os.environ["SEPAY_API_KEY"]
Tiền tố _live_ là quy ước: key test và key thật tồn tại song song theo môi trường. Một commit chứa sk_live_... là một sự cố.
Hai mũi tên, một bước lật trạng thái. Nếu không nhớ gì khác trong chủ đề này, hãy nhớ bức tranh này.
chọn gói, trả tiền trên trang checkout của nhà cung cấp
biết giá, giữ đơn hàng, nắm quyền mở khóa
trang checkout, dữ liệu thẻ, ngân hàng, ví điện tử — tiền nằm ở đây
API = bạn hỏi. Webhook = thế giới báo. Lật trạng thái = sản phẩm của bạn. Đó là thanh toán.
Chạy danh sách này vào ngày bạn đổi từ key test sang key thật. Mỗi mục kiểm tra ngăn một bug mà nếu không, bạn hoặc khách sẽ mất tiền.
Nhà cung cấp phải với tới được từ internet — nếu sau firewall không có route, thanh toán thành công mà app của bạn không bao giờ hay biết.
Request không có chữ ký hợp lệ của nhà cung cấp nhận 401. Đây là thứ ngăn người lạ 'trả tiền' thay user của bạn.
Retry, gửi trùng và curl test đều đáp xuống an toàn: kiểm-trước-khi-lật hoặc bảng sự kiện đã xử lý. Một hiệu ứng cho mỗi đơn.
Mỗi webhook ghép đúng một đơn của bạn, và số tiền khớp. Không khớp thì log + báo động, không bao giờ đoán.
API key trong env var, git bỏ qua; key test trong dev, key thật trong prod. Repo chưa bao giờ nhìn thấy sk_live_....
Làm đúng luồng async và idempotency, giữ dữ liệu thẻ bên phía họ và key của bạn trong môi trường — là bạn đã ship được tiền thật.