Webhook6 tháng 8, 20268 phút đọc

Paddle gửi 60 lần trong hai ngày. Stripe thì giãn cách các lần gửi.

Merchant of record có thời hạn phản hồi ngắn hơn và đường cong thử lại dày hơn. Những gì thay đổi khi indie SaaS chuyển sang Paddle hay Lemon Squeezy.

PADDLE · TỐI ĐA 60 LẦN GỬI TRONG KHOẢNG 2 NGÀY, KHOẢNG 10 LẦN TRONG GIỜ ĐẦUgiờ đầukhoảng 2 ngày3 ngàySTRIPE · EXPONENTIAL BACKOFF TRONG TỐI ĐA 3 NGÀYmột handler chỉ chậm thôi sẽ bị bên này dội liên tục, còn bên kia chỉ thăm dò nhẹ nhàng
Khoảng thời gian ngắn hơn, áp lực rất khác nhau. Paddle dồn khoảng 10 lần gửi vào giờ đầu tiên.

Ngày nay có rất nhiều indie SaaS chạy trên một merchant of record. Paddle và Lemon Squeezy lo thuế bán hàng, VAT và chargeback, một sự đánh đổi thật sự đáng giá với một founder làm một mình không muốn phải nghĩ tới các ngưỡng VAT của EU.

Webhook của họ không giống của Stripe, và những khác biệt thuộc loại chỉ lộ ra khi tải cao. Nếu bạn đã chuyển nguyên một handler (đoạn code xử lý webhook trên server của bạn) viết cho Stripe sang và chỉ đổi phần kiểm tra chữ ký, có hai con số bạn nên biết.

TL;DR

  • Paddle muốn nhận 200 trong vòng năm giây; Stripe hoàn toàn không công bố con số nào
  • Ở live mode, Paddle gửi tối đa 60 lần trong khoảng 2 ngày, khoảng 10 lần trong giờ đầu tiên và khoảng 45 lần trong ngày đầu tiên
  • Sandbox của Paddle hoạt động hoàn toàn khác: 3 lần gửi trong vòng khoảng 2 phút
  • Lemon Squeezy ký bằng X-Signature, một HMAC-SHA256 dạng hex digest tính trên raw body, và cho phép gửi lại từ dashboard
  • Một handler chậm bị trả giá đắt hơn nhiều so với trên Stripe, vì các lần thử lại (retry) của Paddle bắt đầu cách nhau một phút và giãn ra chậm

Năm giây, ghi rõ bằng văn bản

Stripe không bao giờ công bố con số nào, và cộng đồng thường lấy mốc khoảng hai mươi giây. Paddle thì có công bố: endpoint của bạn phải “trả về 200 trong vòng năm giây sau khi nhận request”.

Năm giây đặt Paddle vào cùng nhóm với Shopify chứ không phải Stripe, và đó là khoảng một phần tư thời hạn mà handler Stripe của bạn được viết để đáp ứng. Một handler xử lý đơn hàng, cấp license key, ghi vào database và gửi email chào mừng qua một API bên thứ ba sẽ thoải mái nằm trong hai mươi giây, và cũng thoải mái vượt quá năm giây.

Cấu trúc trụ vững ở đây cũng là cấu trúc trụ vững ở mọi nơi: xác minh, lưu, xác nhận (ack), xử lý sau.

export async function POST(req: Request) {
  const raw = await req.text();                       // raw bytes, before parsing
  if (!verifyPaddleSignature(raw, req.headers.get("paddle-signature"))) {
    return new Response("unauthorized", { status: 401 });
  }

  await queue.publish(raw);                           // durable, ~10ms
  return new Response(null, { status: 200 });         // inside 5s
}

Đường cong thử lại mới là khác biệt thật sự

Lịch ở live mode của Paddle: “chúng tôi gửi tối đa tổng cộng 60 lần trong khoảng hai ngày. Khoảng 10 lần diễn ra trong giờ đầu tiên, và khoảng 45 lần trong ngày đầu tiên”.

Stripe trải một số ít lần gửi trong tối đa ba ngày bằng exponential backoff (khoảng chờ tăng theo cấp số nhân). Khoảng chờ của Paddle bắt đầu từ một phút và tăng chậm, nên phần lớn trong sáu mươi lần gửi của nó rơi vào giai đoạn đầu.

PADDLE · TỐI ĐA 60 LẦN GỬI TRONG KHOẢNG 2 NGÀY, KHOẢNG 10 LẦN TRONG GIỜ ĐẦUgiờ đầukhoảng 2 ngày3 ngàySTRIPE · EXPONENTIAL BACKOFF TRONG TỐI ĐA 3 NGÀYmột handler chỉ chậm thôi sẽ bị bên này dội liên tục, còn bên kia chỉ thăm dò nhẹ nhàng

Hãy xem điều đó nghĩa là gì với một handler không hỏng, chỉ chậm. Nó mất sáu giây vì database đang trục trặc nhất thời. Paddle cắt timeout ở mốc năm giây và thử lại. Trong giờ đầu tiên, chuyện đó lặp lại khoảng mười lần, mỗi lần khởi động thêm một lượt chạy sáu giây nữa của handler, nhắm vào một database vốn đã là nguyên nhân của vấn đề.

Đặt cạnh các bên gửi khác trong tài liệu tham chiếu các nhà cung cấp webhook của chúng tôi, Paddle là trường hợp ngoại lệ. Exponential backoff sinh ra để cho một hệ thống đang chật vật có chỗ hồi phục. Paddle cũng gọi lịch của mình là exponential backoff, nhưng một đường cong dồn vào đầu đến mức này tạo áp lực lớn nhất đúng lúc endpoint của bạn ít chịu nổi nhất. Paddle không vô lý, vì với sự kiện thanh toán, bạn muốn lượt gửi được kiên trì tới cùng, nhưng điều đó có nghĩa là cái giá của một handler chậm tăng nhanh hơn nhiều so với chính handler đó trên Stripe.

Nó cũng có nghĩa là một handler thiếu idempotency (xử lý nhiều lần vẫn cho cùng một kết quả) có thể làm cùng một việc khoảng mười lần trong một giờ, thay vì vài lần trong một ngày. Nếu việc đó là “cấp license” hay “gửi biên nhận”, khách hàng của bạn sẽ nhận ra.

Sandbox sẽ không cho bạn thấy điều này

“Với tài khoản sandbox, chúng tôi gửi tổng cộng 3 lần, tất cả trong vòng khoảng hai phút”.

Ba lần gửi trong khoảng hai phút là một hệ thống khác hẳn với sáu mươi lần trong khoảng hai ngày. Mọi giả định về thời gian mà bạn kiểm chứng trên sandbox đều sai trên production, và sai theo hướng an toàn hơn, kiểu sai tệ nhất. Handler của bạn sẽ trông ổn cho tới đúng lúc nó xử lý tiền thật.

Nếu bạn muốn test hành vi thử lại thực sự quan trọng, cố ý trả về 500 ở live mode với một đích dùng để test và theo dõi giờ đầu tiên.

Xác minh chữ ký

Paddle ký bằng header Paddle-Signature chứa một dấu thời gian (timestamp) và một chữ ký H1, cùng họ với cơ chế t=/v1= của Stripe. Lemon Squeezy ký bằng X-Signature, một HMAC-SHA256 dạng hex digest của raw body trong request, dùng signing secret (khóa bí mật dùng để ký) của webhook.

Cả hai đều có chung cái bẫy mà nhà cung cấp nào cũng có: HMAC được tính trên đúng các byte đã đến. Parse JSON trước rồi serialize lại là digest thay đổi, trong khi payload trong log trông hoàn toàn đúng. Đây là lỗi webhook phổ biến nhất ở mọi nhà cung cấp, và cơ chế của từng nhà cung cấp có trong bài Xác minh chữ ký webhook của Stripe, GitHub và Shopify.

import crypto from "node:crypto";

function verifyLemonSqueezy(raw: string, header: string | null, secret: string) {
  if (!header) return false;
  const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Bước kiểm tra độ dài trước timingSafeEqual rất quan trọng, vì hàm này ném lỗi khi độ dài không khớp thay vì trả về false, và một header dị dạng từ kẻ tấn công không nên gây ra lỗi 500.

Trên App Router của Next.js, await req.text() trước mọi thứ khác. Với serverless nói chung, bài Nhận webhook trên Vercel mà không bị mất nói về body parser lặng lẽ đọc mất body stream.

Idempotency, và một điểm riêng của merchant of record

Paddle gửi một định danh sự kiện mà bạn có thể dùng làm key, và pattern vẫn là unique constraint quen thuộc:

INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;

Không có dòng nào bị ảnh hưởng nghĩa là bạn đã thấy sự kiện đó rồi. Trả về 200 chứ đừng trả lỗi, nếu không bạn vừa dựng một vòng lặp có sáu mươi lần gửi bên trong.

Điểm riêng của merchant of record: các sự kiện vòng đời subscription của cùng một khách hàng có thể đến sát nhau, và một lần thử lại có thể đảo thứ tự của chúng. Một subscription.updated được thử lại sau một giờ có thể đến sau một subscription.canceled và đưa subscription trở lại trạng thái active. Chống trùng lặp không giúp được gì, vì hai sự kiện khác nhau và đều hợp lệ. Bạn cần một version guard (chốt chặn theo phiên bản) dựa trên dấu thời gian của chính sự kiện, cùng pattern được mô tả trong bài Bạn nhận cùng một webhook hai lần.

Với mật độ thử lại của Paddle, một sự kiện cũ có nhiều cơ hội hơn hẳn so với trên Stripe để đến sau một sự kiện mới hơn. Ở đây, đó không phải mối lo trên lý thuyết.

Chuyển từ Stripe sang: cần thay đổi gì

Cắt thời hạn của handler xuống còn một phần tư. Mọi thứ nằm ngoài chuỗi xác minh, lưu, xác nhận đều chuyển sang một worker.

Giả định bản trùng là chuyện thường gặp chứ không hiếm. Khoảng mười lần gửi trong một giờ làm thay đổi bài toán về việc idempotency của bạn cần cẩn thận đến mức nào.

Thêm version guard cho mọi thứ có vòng đời. Nhất là subscription.

Đừng tinh chỉnh dựa trên sandbox. Hành vi thử lại của nó chẳng giống gì production.

Giữ log các raw payload. Lemon Squeezy cho phép gửi lại từ dashboard và Paddle giữ lịch sử lượt gửi, nhưng cả hai đều là bản sao của nhà cung cấp, theo thời gian lưu trữ của nhà cung cấp, và không cái nào trả lời được câu “handler của chúng ta thực sự đã làm gì với nó”.

AnyHook giúp được gì

Năm giây là ràng buộc của tầng truyền tải, và sáu mươi lần gửi là hành vi của tầng truyền tải. Cả hai không còn áp lên code của bạn khi có thứ khác giữ kết nối.

Trỏ notification destination về in.anyhook.net/you/paddle. AnyHook xác minh và lưu tại edge (lớp máy chủ biên của Cloudflare) rồi trả 200 sau khoảng một giây, nên thời hạn năm giây được đáp ứng bởi một worker chỉ làm đúng hai việc, và đường cong sáu mươi lần gửi không bao giờ được kích hoạt vì chẳng có gì để thử lại.

  • Handler của bạn có từ 60 đến 300 giây, tùy gói và trong phạm vi hạn mức compute (tài nguyên tính toán đi kèm gói), và một database chậm không còn kéo theo một lần thử lại sau mỗi vài phút
  • Mọi sự kiện đều được lưu trước khi gửi, được mã hóa khi lưu trữ, nên phát lại (replay) một khung giờ deploy lỗi chỉ cần chọn rồi bấm
  • Lượt gửi tới endpoint của bạn được thử lại theo exponential backoff, đường cong đúng cho một hệ thống đang hồi phục
  • Cảnh báo được gửi ở mốc 1, 5 và 20 lần thất bại liên tiếp, và việc gửi tự động tạm dừng ở mốc 20 để một handler đang hỏng không bị dội request liên tục

Handler của bạn vẫn cần idempotency và version guard, vì cả thử lại lẫn phát lại đều tồn tại.

Điều cần nhớ

Thanh toán qua merchant of record là một sự đánh đổi tốt cho indie SaaS, và cam kết về webhook khắt khe hơn cái bạn có lẽ đang chuyển đi. Năm giây, tối đa sáu mươi lần gửi trong khoảng hai ngày, phần lớn dồn ở đầu. Xác minh trên các byte gốc, xác nhận trước khi xử lý, chống trùng lặp dựa trên event ID của nhà cung cấp, và chặn các lần ghi subscription bằng dấu thời gian của chính sự kiện.

Một handler đã chạy ổn trên Stripe suốt hai năm không mặc nhiên ổn ở đây, và sandbox sẽ không phải thứ báo cho bạn biết.

Tất cả bài viết6 tháng 8, 2026 · 8 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.