🔥 🎁 QUÀ TẶNG khi đăng ký: Phần mềm DNT Digital SaaS FindMe VPS Guardian 12 tháng (~240 USD) và 24 tháng (~480 USD)
Tích hợp PayOS vào .NET: chữ ký HMAC, webhook và 5 lỗi làm mất tiền
ĐTĐược viết bởi Đặng Trí Thanhvào ngày 01/10/2026 lúc 14:20|… lượt xem
Hướng dẫn01/10/2026 · 20 phút đọc

Tích hợp PayOS vào .NET: chữ ký HMAC, webhook và 5 lỗi làm mất tiền

Tích hợp cổng thanh toán là loại việc mà "chạy được" chưa đủ. Nó phải đúng cả khi mạng chập chờn, khi webhook bị gọi hai lần, khi khách bấm huỷ giữa chừng, và khi tên miền của bạn đổi. Bài này là những gì tôi đã làm sai trước khi làm đúng.


1. Tiền đã vào, license thì không

Có một loại lỗi trong hệ thống thanh toán mà bạn chỉ phát hiện khi khách gọi điện.

Khách bấm nút gia hạn, được đưa sang trang thanh toán của PayOS, quét mã, tiền ra khỏi tài khoản. PayOS báo giao dịch thành công. Khách quay về ứng dụng — và license vẫn ghi "còn 3 ngày".

Kiểm tra thì thấy: đơn hàng trong cơ sở dữ liệu vẫn ở trạng thái chờ. Webhook của PayOS đã gọi vào hệ thống, nhưng hệ thống từ chối nó vì chữ ký không khớp. Và vì không khớp nên nó trả về lỗi — để rồi PayOS thử lại nhiều lần, tất cả đều thất bại giống nhau, cho tới khi hết số lần thử.

Không có cảnh báo nào. Không có email. Chỉ có một khách hàng đã trả tiền và đang chờ.

Bài này viết lại toàn bộ đường đi đó: tạo link thanh toán, ký dữ liệu, nhận webhook, xác minh, chống trùng, và năm lỗi cụ thể đã làm tôi mất tiền — kèm cách kiểm tra từng lỗi.

2. PayOS hoạt động theo hai chiều, không phải một

Điểm cần hiểu trước khi viết dòng code nào: tích hợp cổng thanh toán không phải một chiều. Nó là hai luồng riêng biệt, chạy theo hai hướng khác nhau.

Chiều đi — bạn gọi PayOS. Backend của bạn gửi yêu cầu tạo link thanh toán, PayOS trả về một đường dẫn và một mã QR. Khách bấm vào đường dẫn đó, hoặc quét mã, để trả tiền.

Chiều về — PayOS gọi bạn. Khi giao dịch hoàn tất, PayOS gọi vào một endpoint của bạn (webhook) để báo kết quả. Đây là lúc bạn thực sự ghi nhận tiền và gia hạn dịch vụ.

Sai lầm phổ biến nhất của người mới là bỏ qua chiều thứ hai và thay bằng cách "kiểm tra định kỳ": cứ mỗi phút gọi PayOS hỏi xem đơn đã thanh toán chưa. Cách đó có ba nhược điểm chí mạng: chậm (khách phải chờ tới nhịp hỏi kế tiếp), tốn tài nguyên, và quan trọng nhất — nó không phải cơ chế mà cổng thanh toán thiết kế để dùng.

Webhook mới là kênh chính. Và vì webhook là một endpoint công khai, ai cũng gọi được, nên toàn bộ độ tin cậy của luồng tiền phụ thuộc vào một thứ duy nhất: chữ ký số.

Hình 1 – Hai chiều của luồng thanh toán: chiều đi tạo link, chiều về là webhook xác nhận
Hình 1 – Hai chiều của luồng thanh toán: chiều đi tạo link, chiều về là webhook xác nhận

3. Chuẩn bị ba khoá, và đừng để chúng trong mã nguồn

PayOS cấp cho bạn ba giá trị:

Client ID — định danh kênh thanh toán của bạn, gửi kèm mọi yêu cầu.

API Key — khoá xác thực yêu cầu gửi tới PayOS.

Checksum Key — khoá bí mật dùng để ký dữ liệu. Đây là khoá quan trọng nhất, vì cả bảo mật của webhook nằm ở nó.

Ba giá trị này đọc từ cấu hình theo môi trường, không nằm trong mã nguồn:

public sealed class PayOSOptions
{
    public const string SectionName = "PayOS";
    public const string BaseUrlDefault = "https://api-merchant.payos.vn";

    public string? ClientId { get; set; }
    public string? ApiKey { get; set; }
    public string? ChecksumKey { get; set; }

    // URL khách quay về sau khi thanh toán xong / bấm huỷ
    public string ReturnUrl { get; set; } = "https://ten-mien-cua-ban.com/app";
    public string CancelUrl { get; set; } = "https://ten-mien-cua-ban.com/app";

    public bool IsConfigured =>
        !string.IsNullOrWhiteSpace(ClientId) &&
        !string.IsNullOrWhiteSpace(ApiKey) &&
        !string.IsNullOrWhiteSpace(ChecksumKey);
}

Thuộc tính IsConfigured không phải để cho đẹp. Nó cho phép hệ thống chạy được khi chưa có khoá — giao diện ẩn nút thanh toán và hiện hướng dẫn liên hệ, thay vì để khách bấm vào rồi nhận lỗi 500. Đây là chi tiết nhỏ nhưng nó là khác biệt giữa "đang cấu hình" và "đang hỏng".

Đừng bao giờ commit ba khoá này. Nếu bạn dùng nền tảng đám mây, chúng nên nằm trong phần cấu hình của môi trường (biến môi trường hoặc file cấu hình bí mật), không nằm trong file cấu hình được publish.

Yêu cầu tạo link gửi tới endpoint /v2/payment-requests với hai tiêu đề xác thực:

client.DefaultRequestHeaders.Add("x-client-id", opt.ClientId);
client.DefaultRequestHeaders.Add("x-api-key", opt.ApiKey);

Phần thân yêu cầu có bốn trường dữ liệu và một trường chữ ký:

var payload = new Dictionary<string, object?>
{
    ["orderCode"] = orderCode,          // số nguyên, DUY NHẤT cho mỗi đơn
    ["amount"]      = amountVnd,        // đơn vị VND, số nguyên, không có phần thập phân
    ["description"] = description,      // nội dung chuyển khoản hiện cho khách
    ["cancelUrl"]   = opt.CancelUrl,
    ["returnUrl"]   = opt.ReturnUrl,
};
payload["signature"] = SignData(payload, opt.ChecksumKey!);

Order code là trường dễ đặt sai nhất. Nó phải là một số nguyên duy nhất cho mỗi đơn — không phải số hoá đơn hiển thị cho khách, mà là khoá để đối soát ở cả hai chiều. Cách làm an toàn: sinh số ngẫu nhiên trong một khoảng lớn rồi kiểm tra lại trong cơ sở dữ liệu trước khi dùng:

long orderCode = 0;
for (var i = 0; i < 20 && orderCode == 0; i++)
{
    var candidate = Random.Shared.NextInt64(100_000_000, 999_999_999);
    if (!await db.PaymentOrders.AnyAsync(o => o.OrderCode == candidate))
        orderCode = candidate;
}
if (orderCode == 0) throw new InvalidOperationException("Không sinh được mã đơn duy nhất.");

Nếu bạn dùng số tăng dần, hai khách bấm gia hạn cùng lúc có thể nhận cùng một mã — và khi webhook về, bạn không biết phải gia hạn cho ai. Nếu bạn dùng mã có thể đoán được, người khác có thể đoán mã đơn tiếp theo của bạn. Số ngẫu nhiên trong khoảng lớn, có kiểm tra trùng, giải quyết cả hai vấn đề.

Description cũng có luật riêng: chỉ chấp nhận chữ không dấu, số, khoảng trắng, gạch ngang và gạch dưới. Đặt mã đối soát đúng định dạng đó ngay từ đầu, ví dụ FMVPS-123456789. Tôi đã từng gửi nội dung có dấu tiếng Việt và bị từ chối ở đúng tầng này.

Khi thành công, PayOS trả về code = "00" kèm data gồm paymentLinkId, checkoutUrl và qrCode. Ba giá trị đó là thứ bạn trả về cho giao diện: checkoutUrl để mở trang thanh toán, qrCode để hiển thị mã cho khách quét.

5. Chữ ký — phần quyết định toàn bộ tích hợp

Đây là phần tôi làm sai và phải sửa, và cũng là phần mà tài liệu đọc qua thì thấy đơn giản nhưng code sai thì không có thông báo nào rõ ràng.

Thuật toán gồm bốn bước cố định:

Bước một: lọc bỏ mọi trường có giá trị rỗng.

Bước hai: sắp xếp các trường theo thứ tự bảng chữ cái của tên trường.

Bước ba: nối thành chuỗi dạng ten_truong=gia_tri phân cách bằng dấu &, giữ nguyên giá trị, không mã hoá URL.

Bước bốn: tính HMAC-SHA256 với khoá là Checksum Key, rồi đổi sang chuỗi thập lục phân chữ thường.

public static string SignData(IReadOnlyDictionary<string, object?> data, string checksumKey)
{
    var pairs = data
        .Where(kv => kv.Value is not null)
        .Select(kv => new
        {
            k = kv.Key,
            v = kv.Value switch
            {
                string s => s,
                bool b => b ? "true" : "false",
                _ => Convert.ToString(kv.Value, CultureInfo.InvariantCulture) ?? "",
            },
        })
        .OrderBy(x => x.k, StringComparer.Ordinal)
        .Select(x => $"{x.k}={x.v}");

    var query = string.Join("&", pairs);
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(checksumKey));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(query));
    return Convert.ToHexString(hash).ToLowerInvariant();
}

Ba chi tiết trong đoạn code trên là ba lỗi tôi từng mắc, và tôi liệt kê để bạn tránh:

Không mã hoá URL. Đây là lỗi tốn thời gian nhất. Nếu bạn dùng hàm mã hoá URL cho giá trị trước khi ký — điều rất tự nhiên khi tạo chuỗi truy vấn — PayOS sẽ trả về lỗi code 201 với nội dung "signature không hợp lệ". Lỗi này không chỉ ra rằng bạn mã hoá quá tay; nó chỉ nói chữ ký sai. Tôi đã mất một buổi tối để tìm ra rằng chính bước "làm cho đúng chuẩn URL" lại là thứ phá hỏng chữ ký.

Sắp xếp theo mã chữ cái của tên trường, không theo thứ tự bạn viết trong từ điển, không theo thứ tự xuất hiện trong JSON. Nếu bạn dùng cấu trúc dữ liệu không giữ thứ tự chèn, thứ tự khi ký có thể khác thứ tự khi xác minh.

Chữ thường, không chữ hoa. Chuỗi thập lục phân phải đúng dạng chữ thường. Nhiều hàm băm trong .NET trả về chữ hoa, và một lá thư chữ hoa sẽ không khớp với bản chữ thường của PayOS.

Điểm hay của việc gói chữ ký thành một hàm tĩnh dùng chung: bạn dùng cùng một hàm cho cả hai chiều — ký dữ liệu khi gửi và xác minh chữ ký khi nhận. Nếu chữ ký hoạt động ở chiều đi, nó cũng hoạt động ở chiều về; và nếu sai, chỉ sai ở một chỗ để bạn sửa.

Hình 2 – Năm bước tạo chữ ký: lọc rỗng, sắp xếp A-Z, nối RAW, HMAC-SHA256, hex chữ thường
Hình 2 – Năm bước tạo chữ ký: lọc rỗng, sắp xếp A-Z, nối RAW, HMAC-SHA256, hex chữ thường

6. Webhook: xác minh rồi mới tin

Endpoint webhook của bạn là một địa chỉ công khai. Bất kỳ ai biết đường dẫn đều gửi được dữ liệu tới đó — kèm theo một thông báo "đã thanh toán 5 triệu". Nếu bạn tin dữ liệu đó, bạn vừa tặng dịch vụ miễn phí cho người lạ.

Nguyên tắc duy nhất: xác minh chữ ký trước, xử lý sau. Không có ngoại lệ nào.

Cách làm: đọc thân yêu cầu dưới dạng chuỗi thô, lấy trường chữ ký trong phần header hoặc trong thân, rồi tính lại chữ ký trên toàn bộ đối tượng data và so sánh.

var raw = await new StreamReader(http.Request.Body).ReadToEndAsync();
var signature = /* lấy từ header hoặc từ json */ "";
var data = payos.VerifyAndGetData(raw, signature);
if (data is null)
    return Results.Ok(new { success = false });   // chữ ký sai: KHÔNG xử lý, nhưng cũng không gây lỗi HTTP

Hai điểm tinh tế ở đây, và cả hai đều quan trọng trong vận hành thật.

Điểm thứ nhất: phải đọc thân yêu cầu dạng chuỗi thô, không phải dạng đối tượng đã phân tích. Nếu bạn để framework tự động chuyển JSON thành đối tượng rồi mới tính chữ ký, thứ tự trường và định dạng số có thể đã thay đổi. Chữ ký phải được tính trên đúng những byte mà PayOS đã gửi.

Điểm thứ hai: phải đối xử đúng với số và giá trị luận lý. Khi bạn phân tích JSON thành đối tượng, một số nguyên có thể trở thành số thực, và giá trị đúng/sai có thể thành chữ hoa "True" thay vì "true".

Ba nguyên tắc xử lý sau khi xác minh xong:

Không thay đổi trạng thái bằng mã lỗi HTTP. Trả về lỗi 4xx hay 5xx sẽ khiến cổng thanh toán thử lại liên tục — trong nhiều trường hợp có thể biến một lỗi nhỏ thành hàng trăm lần gọi. Với webhook thanh toán, hãy trả về thành công ở tầng giao thức và đặt kết quả thật vào phần thân. Ví dụ khi chữ ký sai, tôi trả về HTTP 200 kèm { success: false }: PayOS không thử lại vô ích, còn tôi thì ghi log để tự kiểm tra.

Ghi lại mọi lần nhận, kể cả những lần từ chối. Khi có sự cố, log webhook là thứ duy nhất trả lời được câu hỏi "cổng thanh toán có gọi không, gọi lúc nào, nội dung ra sao, và tại sao mình từ chối".

Gửi cảnh báo cho chính mình khi webhook đáng lẽ phải xử lý được mà lại không xử lý được. Một webhook chữ ký sai, lặp lại nhiều lần, là dấu hiệu của lỗi cấu hình hoặc tấn công — cả hai đều cần bạn biết ngay.

7. Chống gia hạn trùng: thứ cổng thanh toán không bảo đảm cho bạn

Cổng thanh toán bảo đảm rằng webhook sẽ được gửi, nhưng không bảo đảm nó chỉ được gửi một lần. Có ít nhất ba tình huống dẫn tới việc cùng một giao dịch được thông báo nhiều lần: gọi lại do lần trước hết thời gian chờ, gọi lại theo cơ chế thử lại, hoặc khách thanh toán lại cùng một đơn.

Nếu hàm xử lý của bạn không có tính chống trùng, mỗi lần gọi lại là một lần gia hạn — khách trả tiền một tháng, license cộng hai hoặc ba tháng. Đây là lỗi làm mất tiền mà không ai phát hiện cho tới khi đối soát.

Cách chống trùng đúng gồm bốn phần, và cả bốn đều nằm ở phía bạn, không phải phía cổng thanh toán:

Một, lưu trạng thái đơn trong cơ sở dữ liệu, ví dụ: chờ thanh toán, đã thanh toán, đã huỷ, thất bại.

Hai, chỉ xử lý đơn đang ở trạng thái chờ. Nếu đơn đã đánh dấu đã thanh toán, lần gọi lại chỉ trả về thành công mà không làm gì thêm.

Ba, đánh dấu trạng thái trong cùng một giao dịch cơ sở dữ liệu với việc gia hạn. Nếu bạn cập nhật trạng thái sau khi gia hạn, sẽ có một khoảng thời gian ngắn mà hai yêu cầu song song cùng thấy đơn "chưa xử lý" — và cùng xử lý.

Bốn, lưu mã giao dịch của cổng thanh toán, không chỉ mã đơn của bạn. Mã đơn là thứ bạn tạo; mã giao dịch là thứ cổng cấp, và nó là bằng chứng duy nhất khi bạn phải đối chất với họ về một giao dịch cụ thể.

Một điểm cộng nếu bạn dùng nhiều cổng thanh toán song song: hãy tách mã đối soát nội bộ khỏi tên cổng. Ví dụ, mã đơn dạng FM-123456789 là duy nhất trong hệ thống của bạn, còn cổng nào xử lý thì lưu ở trường riêng. Khi đó, một đơn có thể được tạo qua cổng này, khách bỏ ngang, rồi thanh toán qua cổng khác — và bạn vẫn biết đó là cùng một đơn.

8. Năm lỗi làm mất tiền, và cách chúng xuất hiện

Phần này là năm lỗi tôi đã gặp thật, xếp theo mức độ thiệt hại.

Lỗi 1 — Ký dữ liệu bằng chuỗi đã mã hoá URL

Triệu chứng: tạo link thanh toán thất bại ngay từ đầu, cổng trả về mã lỗi chữ ký không hợp lệ.

Vì sao khó tìm: thông báo lỗi nói về chữ ký, không nói về mã hoá; và việc mã hoá URL là phản xạ đúng trong gần như mọi tác vụ khác.

Cách chữa: ký trên giá trị thô, chỉ mã hoá ở bước gửi HTTP — và ở đây bạn không cần mã hoá vì dữ liệu nằm trong thân yêu cầu dạng JSON.

Lỗi 2 — Địa chỉ quay về trỏ vào tên miền đã chết

Triệu chứng: khách thanh toán thành công, bị đưa về một trang báo lỗi không tìm thấy. Tiền đã vào, khách tưởng hệ thống lỗi và gọi điện.

Nguyên nhân: returnUrl được đặt một lần khi dự án mới chạy, sau đó tên miền sản phẩm đổi nhưng cấu hình này không được cập nhật — đúng loại lỗi "cấu hình nằm ngoài mã nguồn nên không ai nhớ".

Cách chữa: đưa returnUrl và cancelUrl vào cùng nơi với các cấu hình khác, và thêm chúng vào danh sách kiểm tra mỗi lần đổi tên miền. Hai địa chỉ này phải là tên miền đang chạy thật, có HTTPS, và trỏ tới một trang tồn tại.

Lỗi 3 — Không chống trùng, khách được gia hạn hai lần

Triệu chứng: không có triệu chứng nào. Hệ thống chạy "quá tốt": khách trả một tháng, nhận hai tháng.

Cách chữa: trạng thái đơn, chỉ xử lý đơn đang chờ, và cập nhật trạng thái trong cùng giao dịch với việc gia hạn — như mục 7.

Lỗi 4 — Mã đơn không thật sự duy nhất

Triệu chứng: webhook về với một mã đơn, nhưng cơ sở dữ liệu có hai bản ghi cùng mã. Hệ thống gia hạn cho bản ghi đầu tiên tìm thấy.

Nguyên nhân: mã đơn sinh từ thời gian tới giây, hoặc từ số tăng dần không có khoá duy nhất ở tầng cơ sở dữ liệu.

Cách chữa: sinh số ngẫu nhiên trong khoảng lớn, kiểm tra trùng trước khi dùng, và đặt ràng buộc duy nhất trên cột mã đơn. Ràng buộc ở tầng cơ sở dữ liệu là thứ duy nhất chặn được hai yêu cầu chạy song song.

Lỗi 5 — Nội dung chuyển khoản có ký tự cổng thanh toán không nhận

Triệu chứng: tạo link thất bại với thông báo về nội dung, hoặc nội dung hiện cho khách bị cắt.

Cách chữa: chỉ dùng chữ không dấu, số, khoảng trắng, gạch ngang, gạch dưới — và kiểm tra ngay ở tầng tạo đơn, đừng đợi tới lúc gọi cổng thanh toán mới phát hiện.

Thêm một lỗi phụ nhưng nghiêm trọng về bảo mật: ghi khoá bí mật vào log. Khi gỡ lỗi, nhiều người in cả chuỗi dữ liệu đã ký và cả khoá ký ra file log — mà log thì hay được gửi cho nhau khi nhờ hỗ trợ. Không bao giờ ghi khoá ký. Nếu cần đối chiếu, in chữ ký, không in khoá.

Hình 3 – Năm lỗi làm mất tiền: triệu chứng, nguyên nhân và cách chữa
Hình 3 – Năm lỗi làm mất tiền: triệu chứng, nguyên nhân và cách chữa

9. Sau khi xác minh: một hàm áp dụng cho mọi cổng

Khi đã xác minh chữ ký và chống trùng, phần còn lại là nghiệp vụ: gia hạn dịch vụ hoặc license, ghi nhận hoa hồng nếu có, gửi email xác nhận cho khách, và thông báo cho chính bạn.

Hãy viết phần này thành một hàm duy nhất dùng chung cho mọi cổng thanh toán:

ApplyPaidOrderAsync(order, gatewayName) →
    đánh dấu đơn đã thanh toán
    cộng thời hạn vào license
    ghi hoa hồng (nếu có)
    gửi email xác nhận cho khách
    thông báo nội bộ
    trả về (số ngày đã cộng, ngày hết hạn mới)

Lợi ích không nằm ở việc tiết kiệm vài dòng code, mà ở chỗ mọi cổng đều phải đi qua cùng một đoạn đường. Nếu sau này bạn thêm một cổng thứ ba, bạn chỉ cần viết phần xác minh chữ ký của cổng đó rồi gọi vào hàm này. Nếu có lỗi trong nghiệp vụ gia hạn, nó sai ở một chỗ và bạn sửa một lần.

Điều này cũng giúp bạn giữ được một tính chất quan trọng: mọi đơn đã thanh toán đều để lại dấu vết giống nhau, bất kể khách trả qua kênh nào. Khi cần đối soát cuối tháng, bạn chỉ phải đọc một bảng.

10. Kiểm thử trước khi nhận tiền thật

Việc kiểm thử ở đây có một khó khăn cơ bản: bạn không thể dùng thẻ thật để thử, và bạn cũng không muốn chờ một giao dịch thật để biết code đúng hay sai.

Năm phép thử nên làm, theo thứ tự từ rẻ tới đắt:

Thử chữ ký bằng chính code của bạn. Lấy một bộ dữ liệu mẫu, tính chữ ký, rồi tự xác minh lại. Nếu cùng một hàm mà cho ra hai kết quả khác nhau cho cùng dữ liệu, bạn có lỗi về thứ tự trường hoặc định dạng số.

Thử webhook bằng yêu cầu giả có chữ ký đúng. Bạn tự tính chữ ký cho một khối dữ liệu và gửi vào endpoint của mình — đây là cách tốt nhất để kiểm tra logic xử lý mà không cần giao dịch thật.

Thử webhook với chữ ký sai. Hệ thống phải từ chối, không được gia hạn, và phải ghi log. Nếu nó vẫn gia hạn, bạn đang có lỗ hổng nghiêm trọng.

Thử gửi cùng một webhook hai lần. Lần thứ hai không được cộng thêm ngày. Đây là phép thử phân biệt hệ thống "chạy được" và hệ thống "dùng được với tiền thật".

Thử toàn bộ luồng trên môi trường thử của cổng thanh toán, với số tiền nhỏ nhất có thể, rồi tự đối chiếu: đơn hàng, trạng thái, ngày hết hạn của license, email xác nhận, thông báo nội bộ — đủ năm thứ.

Một mẹo nhỏ nhưng tiết kiệm thời gian: hãy đặt mã đối soát in ra trong nội dung chuyển khoản ngay cả trên môi trường thử. Khi bạn thử 10 giao dịch trong một buổi, việc nhìn vào sao kê và biết ngay giao dịch nào ứng với đơn nào là khác biệt giữa mười phút và một buổi chiều.

11. Checklist 16 điểm trước khi bật cổng thanh toán thật

Khoá và cấu hình

  1. Ba khoá (Client ID, API Key, Checksum Key) nằm ngoài mã nguồn và không bị publish ghi đè.
  2. Khoá bí mật không xuất hiện trong log ở bất kỳ mức log nào.
  3. returnUrl và cancelUrl là tên miền đang chạy thật, có HTTPS, trỏ tới trang tồn tại.
  4. Có cờ cho biết "cổng đã cấu hình" và giao diện ẩn nút thanh toán khi chưa cấu hình.

Tạo đơn

  1. Mã đơn là số nguyên duy nhất, sinh ngẫu nhiên, có kiểm tra trùng, và có ràng buộc duy nhất ở tầng dữ liệu.
  2. Nội dung chuyển khoản chỉ dùng chữ không dấu, số, khoảng trắng, gạch ngang, gạch dưới.
  3. Số tiền là số nguyên, đơn vị VND, không có phần thập phân.
  4. Có giới hạn tần suất tạo đơn để chống spam.

Chữ ký

  1. Hàm ký không mã hoá URL giá trị.
  2. Sắp xếp theo tên trường, thứ tự bảng chữ cái.
  3. Kết quả băm ở dạng chữ thường.
  4. Cùng một hàm dùng cho cả tạo yêu cầu lẫn xác minh webhook.

Webhook

  1. Đọc thân yêu cầu dạng chuỗi thô và xác minh chữ ký trước mọi xử lý.
  2. Chữ ký sai thì không xử lý, có ghi log, có cảnh báo nội bộ.
  3. Trả về thành công ở tầng HTTP để tránh vòng lặp thử lại, kết quả thật nằm trong phần thân.

Nghiệp vụ

  1. Có trạng thái đơn; chống xử lý trùng; trạng thái đơn và việc gia hạn được cập nhật trong cùng một giao dịch — và đã thử bằng cách gửi webhook hai lần.

Mục 16 là mục duy nhất mà nếu bạn bỏ qua, thiệt hại sẽ xuất hiện dưới dạng con số trong báo cáo doanh thu chứ không phải một thông báo lỗi.

12. Câu hỏi thường gặp

Có cần chứng chỉ HTTPS cho endpoint webhook không? Có. Cổng thanh toán chỉ gọi tới địa chỉ HTTPS hợp lệ. Nếu bạn đang phát triển ở máy cá nhân, hãy dùng một dịch vụ tạo đường hầm công khai tạm thời để thử, nhưng đừng để nó tồn tại ở môi trường chạy thật.

Webhook về chậm thì sao? Bình thường. Hãy thiết kế giao diện để trạng thái đơn được cập nhật khi khách quay về, và nếu khách quay về trước khi webhook tới, hiển thị trạng thái "đang xử lý" kèm hướng dẫn chờ trong ít phút — thay vì báo lỗi.

Có nên cho khách bấm "kiểm tra lại" hay không? Nên, nhưng nút đó không được tự đánh dấu đơn đã thanh toán. Nó chỉ nên đọc trạng thái hiện tại. Chỉ webhook đã xác minh mới được ghi nhận tiền — nếu không, bạn vừa tạo ra một đường để tự gia hạn miễn phí.

Dùng nhiều cổng thanh toán có phức tạp hơn nhiều không? Phần khó nhất — nghiệp vụ sau khi xác minh — không tăng, nếu bạn làm đúng như mục 9. Phần tăng thêm chỉ là một hàm xác minh chữ ký cho mỗi cổng.

Có cần lưu toàn bộ thân webhook không? Rất nên. Lưu chuỗi thô kèm thời điểm nhận và kết quả xử lý. Khi có tranh chấp với khách hoặc với cổng thanh toán, đây là bằng chứng đầy đủ nhất mà bạn có trong tay.

Chữ ký sai triền miên thì xử lý thế nào? Kiểm tra theo thứ tự: đúng khoá chưa, có đang mã hoá URL không, thứ tự trường, định dạng chữ hoa chữ thường, và cuối cùng là bạn có đang ký trên dữ liệu thô hay trên dữ liệu đã qua xử lý. Trong kinh nghiệm của tôi, lỗi nằm ở bước cuối cùng nhiều hơn cả.

13. Kết

Tích hợp cổng thanh toán không khó ở phần gọi API. Nó khó ở ba chỗ: chữ ký đúng chuẩn, xác minh trước khi tin, và không bao giờ xử lý một đơn hai lần.

Ba chỗ đó không có ngoại lệ, vì mỗi chỗ đều dẫn thẳng tới tiền — hoặc mất tiền của bạn, hoặc tạo tiền cho người khác. Và cả ba đều nằm ở phía bạn, không phải phía cổng thanh toán: cổng chỉ bảo đảm gửi thông báo, không bảo đảm nó đúng một lần, và không bảo đảm hệ thống của bạn hiểu đúng.

Nếu bạn đang làm phần này, hãy bắt đầu bằng hai việc nhỏ kiểm chứng được ngay: viết một hàm ký duy nhất dùng cho cả hai chiều, và gửi thử cùng một webhook hai lần để xem hệ thống có cộng ngày hai lần hay không. Hai việc đó mất chưa tới một giờ, và chúng là nền cho mọi thứ còn lại.

Đọc xong rồi? Hãy thử ngay — miễn phí 7 ngày.
📬

Weekly Digest — Nhận Bản Tin Hàng Tuần

Nhận các bài viết phân tích kỹ thuật chuyên sâu, thuật toán giao dịch tự động (Trading Bot) và các giải pháp công nghệ mới nhất từ Hướng Nghiệp Dữ Liệu.