→ From Coder to Shipper
From Coder to Shipper · Thanh toán

Thanh toán — API + webhook

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

Toàn cảnh

Tiền thật là việc của người khác

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.

Bạn không xây ngân hàng

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.

Bạn không bao giờ chạm vào dữ liệu thẻ

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.

Nhà cung cấp đưa bạn hai công cụ

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ứ đó.

Bạn xây logic kinh doanh

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.

Toàn cảnh

Dàn nhân vật — bốn vai, một thanh toán

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.

🧑 Khách

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ử.

① pay ▶ bấm 'Trả tiền'

🐍 App của bạn

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ọ.

② API ▶ tạo thanh toán

💳 Nhà cung cấp

ví dụ Sepay — một gateway. Dựng trang checkout, giữ dữ liệu thẻ, báo lại qua webhook.

③ settle ▶ thu tiền

🏦 Ngân hàng + ví điện tử

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.

④ WEBHOOK ◀ …vài phút sau, nhà cung cấp POST về URL CỦA BẠN: payment.succeeded. Chuyến về của cả câu chuyện — và cũng là phần async.

①–③ đồ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.
Toàn bộ chủ đề trong một câu
01
Phần 1

Vì sao cần nhà cung cấp — và họ là gì

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ó.

Nhà cung cấp · thỏa thuận

Nhà cung cấp đưa bạn đúng ba thứ

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.

API — bạn gọi nó

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 thanh toán — khách dùng

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ó.

Webhook — nó gọi bạ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ứ.

Nhà cung cấp · phân chia việc

Phân chia việc cho gọn

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.

Việc của nhà cung cấp

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ê.

Việc của bạn

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.

Nhà cung cấp · ranh giới đỏ

Vì sao 'không bao giờ chạm vào dữ liệu thẻ' CHÍNH là kiến trúc

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 lưu số thẻ

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ủ.

Nhà cung cấp là 'két 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.

Bí mật duy nhất của bạn

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.

02
Phần 2

Luồng — từ 'Trả tiền' đến mở khóa

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.

Luồng · toàn cảnh

Luồng thanh toán năm nhịp

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.

1
Khách chọn gói
ví dụ Pro — 200.000đ / tháng
2
App của bạn tạo thanh toán — API nhà cung cấp
amount · currency · order_ref · webhook_url → checkout URL
3
Nhà cung cấp hiện trang checkout — khách trả tiền
thẻ hoặc ví điện tử, trên trang của nhà cung cấp — không phải của bạn
4
⏰ Nhà cung cấp gọi webhook của bạn (async, vài phút sau)
POST /webhooks/pay { "event": "payment.succeeded", ... }
5
App của bạn mở khóa gói
đơn → đã trả, bật feature flag, gửi biên lai — state machine của bạn dịch chuyển

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.

Luồng · nhịp 2

Tạo một thanh toán — khoảng chục dòng

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.

create_payment.py
# 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ả'.

Luồng · cái bẫy

Cái 200 không phải là tiền

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ả'.

200 từ create-payment nghĩa là

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.

Tiền do ai báo

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.

Luồng · câu trả lời

Câu trả lời của API, trực tiếp

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ả).

ops@vps — zsh
# your app POSTs the create-payment request (snippet above)
HTTP/1.1 200 OK
Content-Type: application/json
{"id": "pay_a1b2c3", "status": "created", "url": "https://pay.sepay.example/p/a1b2c3"}
# status is 'created' — NOT 'paid'. The customer still has to pay.
# you redirect the customer's browser to the 'url' above
03
Phần 3

Webhook — nhà cung cấp gọi bạn

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.

Webhook · nó là gì

Webhook, chính xác

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.

Một URL bạn giao cho họ

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ột gói JSON, một sự kiện mỗi lần

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.

Async theo định nghĩa

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.

Coi nó như một vị khách không đáng tin

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.

Webhook · mặt gương

API vs webhook — cùng một thứ, chiếu gương

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ạnhAPIWebhook
Ai mở đầuBạn — app của bạn gọiNhà cung cấp — họ gọi bạn
Khi nàoTheo 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_refSự 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
Webhook · khoảng trống

Khoảng trống async, trực quan hóa

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.

t = 0s
App của bạn gọi API — đồng bộ, xong trong mili giây; nhận về URL checkout
t ≈ 10s
Khách trả tiền — thẻ / ví điện tử / 3-D Secure, trên trang của nhà cung cấp
t ≈ 90s
Nhà cung cấp đối soát, rồi POST webhook của bạn — payment.succeeded
t ≈ 90s
Server của bạn phản ứng — xác minh chữ ký → tìm order_ref → mở khóa gói
t ≈ 92min? …và có thể CÙNG sự kiện đó đến lần nữa: cái 200 đầu của bạn chưa tới nơi, nên nhà cung cấp retry. Sự kiện trùng là lưu lượng bình thường, không phải sự cố.

Bên trái webhook: request/response. Bên phải webhook: thế giới vận động, bạn phản ứng.

Webhook · payload

Một sự kiện thật — nhỏ là có chủ đích

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.

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

Webhook · handler

Endpoint webhook của bạn, đầu cuối

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.

webhook.py
@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.

Webhook · hợp đồng

Hợp đồng webhook — bốn lời hứa

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.

Trả 200 nhanh

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.

Đợi sẵn việc retry — luôn luôn

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.

Xác minh trước khi tin

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í.

Sự kiện là sự thật, không phải UI

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.

Webhook · retry thực tế

Xem một lần retry diễn ra

Đâ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.

ops@vps — zsh
# your server was busy for 2 seconds; the provider doesn't know why
POST /webhooks/pay -> 503 (will retry in 2 min)
POST /webhooks/pay -> 200 ok
# after BOTH deliveries — one unlock, never two
$ sqlite> select status from orders where ref = 'order_8123';
paid
Webhook · đối soát

Đối soát — sự kiện của họ, đơn của bạn

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.

Mang theo tham chiếu của chính bạ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.

Ghép theo ref, không theo id của họ

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.

Đối chiếu lại số tiề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.

Log mọi sự kiện, kể cả trùng

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.

04
Phần 4

Làm cho an toàn

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.

An toàn · idempotency

Idempotency — cùng sự kiện, nhiều lần, một hiệu ứng

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.

Cái bug

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ứ.

Lời giải: kiểm tra trước khi lật

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ất khả xâm phạm: nhớ những gì đã thấy

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.

200 nghĩa là 'tôi hiểu bạ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.

An toàn · idempotency

Cái khóa, trong sáu dòng

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.

idempotent.py
@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.

An toàn · bí mật

Bí mật — key, URL, và ranh giới

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.

API key nằm phía server

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.

URL webhook bán công khai

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ỉ.

Đổi key trước khi nó 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.

An toàn · bí mật

Key sống trong môi trường

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.example
# .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ố.

Tổng kết

Toàn bộ thanh toán trong một bức tranh

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.

🧑 Khách

chọn gói, trả tiền trên trang checkout của nhà cung cấp

pays ▶

🐍 App của bạn

biết giá, giữ đơn hàng, nắm quyền mở khóa

① API ▶ (sync) ◀ ② WEBHOOK (async)

💳 Nhà cung cấp (vd Sepay)

trang checkout, dữ liệu thẻ, ngân hàng, ví điện tử — tiền nằm ở đây

🔓 Khi webhook thành công: xác minh chữ ký → ghép order_ref → kiểm tra số tiền → lật đơn sang đã trả → mở khóa. Idempotent, có log, tối đa một lần.

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.

Tổng kết · lên production

Năm kiểm tra trước khi có tiền thật

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.

URL webhook là https công khai

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.

Chữ ký được xác minh

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.

Handler là idempotent

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.

order_ref khớp, số tiền đã kiểm tra

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.

Key nằm trong môi trường

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_....

Một API bạn gọi. Một webhook gọi bạn.

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.