Banyak SaaS indie sekarang berjalan di atas merchant of record. Paddle dan Lemon Squeezy menangani pajak penjualan, VAT, dan chargeback, dan itu pertukaran yang benar-benar menguntungkan bagi founder solo yang tidak ingin memikirkan ambang VAT Uni Eropa.
Webhook-nya tidak berbentuk sama dengan milik Stripe, dan perbedaannya termasuk jenis yang hanya muncul saat beban tinggi. Jika Anda memindahkan handler Stripe dan mengganti bagian verifikasi tanda tangan (signature), ada dua angka yang perlu Anda ketahui.
TL;DR
- Paddle meminta
200dalam lima detik; Stripe sama sekali tidak memublikasikan angkanya - Mode live Paddle melakukan hingga 60 upaya dalam sekitar 2 hari, sekitar 10 di jam pertama dan sekitar 45 di hari pertama
- Sandbox Paddle berperilaku sama sekali berbeda: 3 upaya dalam sekitar 2 menit
- Lemon Squeezy menandatangani dengan
X-Signature, digest hex HMAC-SHA256 atas raw body, dan bisa mengirim ulang event dari dashboard - Handler yang lambat dihukum jauh lebih berat di sini daripada di Stripe, karena percobaan ulang (retry) Paddle dimulai dengan selang satu menit dan merenggang perlahan
Lima detik, secara tertulis
Stripe tidak pernah memublikasikan angka, dan komunitas berpatokan pada sekitar dua puluh detik. Paddle memublikasikannya: endpoint Anda “Returns 200 within five seconds of receiving a request.”
Lima detik menempatkan Paddle di kelompok yang sama dengan Shopify, bukan Stripe, dan itu kira-kira seperempat dari batas waktu yang menjadi acuan saat handler Stripe Anda ditulis. Handler pemenuhan pesanan yang menerbitkan kunci lisensi, menulis ke database, dan mengirim email sambutan lewat API pihak ketiga dengan mudah selesai dalam dua puluh detik, dan dengan mudah melewati lima.
Pola yang bertahan sama dengan yang bertahan di mana pun: verifikasi, simpan, konfirmasi penerimaan, proses belakangan.
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
}
Kurva percobaan ulang adalah perbedaan yang sebenarnya
Jadwal mode live Paddle: “we make up to 60 attempts in total over about two days. Around 10 attempts happen in the first hour, and around 45 in the first day.”
Stripe menyebar segelintir upaya dalam rentang hingga tiga hari dengan backoff eksponensial. Jeda Paddle dimulai dari satu menit dan bertambah perlahan, jadi sebagian besar dari enam puluh upayanya tiba di awal.
Pikirkan artinya bagi handler yang tidak rusak, hanya lambat. Handler butuh enam detik karena database Anda sedang tersendat. Paddle menganggapnya timeout di detik kelima lalu mencoba ulang. Dalam jam pertama itu terjadi sekitar sepuluh kali, masing-masing memulai lagi eksekusi handler selama enam detik, terhadap database yang sudah menjadi sumber masalahnya.
Jika disandingkan dengan pengirim lain di referensi provider webhook kami, Paddle adalah pencilannya. Backoff eksponensial ada untuk memberi sistem yang kewalahan ruang untuk pulih. Paddle juga menyebut jadwalnya backoff eksponensial, tetapi kurva yang menumpuk di awal seperti ini memberi tekanan paling besar tepat saat endpoint Anda paling tidak sanggup menanggungnya. Paddle tidak bersikap tidak masuk akal, karena untuk event penagihan Anda memang ingin pengirimannya gigih, tetapi ini berarti biaya handler yang lambat berlipat jauh lebih cepat daripada handler yang sama di Stripe.
Ini juga berarti handler yang tidak idempoten bisa mengerjakan pekerjaan yang sama sekitar sepuluh kali dalam satu jam, bukan beberapa kali dalam sehari. Jika pekerjaan itu “terbitkan lisensi” atau “kirim kuitansi”, pelanggan Anda akan menyadarinya.
Sandbox tidak akan menunjukkan ini kepada Anda
“For sandbox accounts, we make 3 attempts in total, all within about two minutes.”
Tiga upaya dalam sekitar dua menit adalah sistem yang berbeda dari enam puluh dalam sekitar dua hari. Setiap asumsi waktu yang Anda validasi di sandbox salah di production, dan salahnya ke arah yang lebih aman, jenis salah yang paling buruk. Handler Anda akan terlihat baik-baik saja sampai saat ia menangani uang sungguhan.
Jika Anda ingin menguji perilaku percobaan ulang yang penting, sengaja kembalikan 500 di mode live ke tujuan uji, lalu amati jam pertamanya.
Verifikasi tanda tangan
Paddle menandatangani dengan header Paddle-Signature yang berisi stempel waktu (timestamp) dan tanda tangan H1, secara umum dalam keluarga yang sama dengan skema t=/v1= milik Stripe. Lemon Squeezy menandatangani dengan X-Signature, digest hex HMAC-SHA256 dari raw body permintaan yang dihitung dengan signing secret webhook Anda.
Keduanya punya jebakan yang sama dengan semua provider: HMAC dihitung atas byte persis yang tiba. Parse JSON lebih dulu lalu serialisasi ulang, dan digest-nya berubah, sementara payload di log Anda terlihat benar sepenuhnya. Ini bug webhook paling umum di semua provider, dan mekanisme per provider ada di Verifikasi Tanda Tangan Webhook Stripe, GitHub, dan 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);
}
Pemeriksaan panjang sebelum timingSafeEqual penting, karena fungsi itu melempar error saat panjangnya tidak sama alih-alih mengembalikan false, dan header rusak dari penyerang tidak semestinya menghasilkan 500.
Di Next.js App Router, await req.text() sebelum apa pun. Untuk serverless secara umum, Menerima Webhook di Vercel tanpa Ada yang Hilang membahas body parser yang diam-diam menghabiskan stream body.
Idempotensi, dengan satu keanehan khas merchant of record
Paddle mengirim identifier event yang bisa Anda jadikan key, dan polanya adalah unique constraint yang biasa:
INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;
Nol baris yang terpengaruh berarti Anda sudah pernah melihatnya. Kembalikan 200, bukan error, atau Anda sudah membuat loop berisi enam puluh upaya.
Keanehan khas merchant of record: event siklus hidup langganan untuk satu pelanggan bisa tiba berdekatan, dan percobaan ulang bisa mengacak urutannya. subscription.updated yang dicoba ulang sejam kemudian bisa tiba setelah subscription.canceled dan mengembalikan langganan menjadi aktif. Deduplikasi tidak membantu, karena kedua event berbeda dan keduanya sah. Anda butuh pengaman versi yang berpatokan pada stempel waktu event itu sendiri, pola yang sama dengan yang dijelaskan di Anda Menerima Webhook yang Sama Dua Kali.
Dengan kepadatan percobaan ulang Paddle, event yang sudah usang punya jauh lebih banyak kesempatan daripada di Stripe untuk tiba setelah event yang lebih baru. Di sini, ini bukan kekhawatiran teoretis.
Pindah dari Stripe: apa yang perlu diubah
Pangkas batas waktu handler Anda menjadi seperempatnya. Apa pun yang bukan bagian dari verifikasi-simpan-konfirmasi dipindah ke worker.
Anggap duplikat itu umum, bukan langka. Sekitar sepuluh upaya dalam satu jam mengubah hitungan seberapa cermat idempotensi Anda harus dibuat.
Tambahkan pengaman versi pada apa pun yang punya siklus hidup. Terutama langganan.
Jangan menyetel berdasarkan sandbox. Perilaku percobaan ulangnya sama sekali tidak mirip production.
Simpan log raw payload. Lemon Squeezy bisa mengirim ulang dari dashboard dan Paddle menyimpan riwayat pengiriman, tetapi keduanya salinan milik provider, dengan masa simpan provider, dan tidak satu pun menjawab “apa sebenarnya yang dilakukan handler kami dengan event itu”.
Peran AnyHook
Lima detik adalah batasan transport, dan enam puluh upaya adalah perilaku transport. Keduanya tidak lagi berlaku untuk kode Anda begitu ada pihak lain yang memegang koneksinya.
Arahkan tujuan notifikasi di Paddle ke in.anyhook.net/you/paddle. AnyHook memverifikasi dan menyimpan di edge lalu mengembalikan 200 dalam sekitar satu detik, sehingga batas waktu lima detik dipenuhi oleh worker yang hanya mengerjakan dua hal, dan kurva enam puluh upaya tidak pernah aktif karena tidak ada yang perlu dicoba ulang.
- Handler Anda mendapat 60 sampai 300 detik, tergantung paket dan dalam batas jatah komputasi (compute allowance) paket tersebut, dan database yang lambat tidak lagi mengundang percobaan ulang setiap beberapa menit
- Setiap event disimpan sebelum dikirim, dengan enkripsi saat disimpan (at rest), jadi memutar ulang (replay) jendela deploy yang buruk cukup dengan memilih dan mengeklik
- Pengiriman ke endpoint Anda dicoba ulang dengan backoff eksponensial, kurva yang tepat untuk sistem yang sedang pulih
- Peringatan dikirim pada 1, 5, dan 20 kegagalan berturut-turut, dan pengiriman dijeda otomatis pada kegagalan ke-20 agar handler yang rusak tidak terus digempur
Handler Anda tetap butuh idempotensi dan pengaman versi, karena percobaan ulang dan putar ulang sama-sama ada.
Kesimpulan
Penagihan lewat merchant of record adalah pertukaran yang baik untuk SaaS indie, dan kontrak webhook-nya lebih ketat daripada yang mungkin sedang Anda tinggalkan. Lima detik, hingga enam puluh upaya dalam sekitar dua hari, sebagian besar di awal. Verifikasi terhadap raw byte, konfirmasi penerimaan sebelum bekerja, lakukan deduplikasi berdasarkan event ID dari provider, dan lindungi penulisan langganan dengan stempel waktu event itu sendiri.
Handler yang baik-baik saja di Stripe selama dua tahun tidak otomatis baik-baik saja di sini, dan bukan sandbox yang akan memberi tahu Anda.