Handler webhook di serverless gagal dengan pola yang spesifik dan berulang, dan hampir tidak ada yang terlihat seperti bug yang sebenarnya. Verifikasi tanda tangan (signature) gagal pada payload yang terbukti benar. Pekerjaan yang dijadwalkan setelah respons tidak pernah berjalan. Handler yang lolos setiap tes lokal mengalami timeout hanya di production, hanya saat beban tinggi.
Semua ini bukan karena Vercel menyulitkan. Ini akibat dari model eksekusi yang hidup sebatas satu permintaan bertemu protokol yang mengandaikan server yang terus berjalan. Berikut lima masalah yang paling sering muncul, sesuai urutan Anda akan menemuinya.
TL;DR
await req.text()sebelum apa pun mem-parse body, atau setiap verifikasi tanda tangan akan gagal- Jangan tergoda memakai
runtime = "edge". Node.js di Fluid Compute adalah default yang tepat waitUntilmemperpanjang fungsi melewati respons, tetapi tidak persisten. Ini bukan antrean- Cold start ikut dihitung dalam batas waktu provider, dan Shopify hanya memberi Anda 5 detik
- Batas waktu sebenarnya untuk handler Anda ditentukan oleh provider yang paling ketat, bukan oleh Vercel
1. Masalah raw body
Ini masalah pertama yang dialami semua orang, dan pesan error-nya sama sekali tidak membantu karena payload terlihat sempurna di log.
Setiap provider menandatangani byte persis yang dikirimnya. Stripe menghitung HMAC dari raw body dengan prefiks stempel waktu (timestamp), Shopify menghitung HMAC dari raw body, GitHub menghitung HMAC dari raw body. Jika ada apa pun di antara jaringan dan panggilan verifikasi Anda yang mem-parse JSON lalu menyerialisasinya ulang, urutan key atau spasi bergeser satu byte dan HMAC tidak lagi cocok. Datanya identik, tetapi tanda tangannya tidak.
Di App Router, baca teksnya dulu, lalu parse dari string yang sudah Anda pegang:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const raw = await req.text(); // exact bytes, before any parsing
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("invalid signature", { status: 400 }); // no details to the caller
}
await queue.publish(event);
return new Response(null, { status: 200 });
}
Ada dua hal yang perlu diingat baik-baik.
Jangan pernah memanggil await req.json() lebih dulu. Setelah itu Anda tidak bisa lagi mendapatkan byte aslinya: stream sudah terbaca habis, dan hasil serialisasi ulang Anda bukan yang ditandatangani.
Pages Router mengharuskan body parser dinonaktifkan secara eksplisit. Jika Anda masih memakai pages/api, tetapkan export const config = { api: { bodyParser: false } } dan baca raw stream sendiri. Sebagian besar laporan “jalan di lokal tetapi tidak setelah di-deploy” berawal dari tidak adanya baris seperti ini.
Untuk format tanda tangan tiap provider (skema t=/v1= milik Stripe, prefiks sha256= milik GitHub, base64 milik Shopify), lihat Verifikasi Tanda Tangan Webhook Stripe, GitHub, dan Shopify. Selain itu, pakai fungsi verifikasi dari SDK provider jika tersedia, alih-alih menulisnya sendiri: fungsi itu sudah menangani perbandingan constant-time dan toleransi stempel waktu untuk Anda, dua hal yang mudah keliru secara halus.
2. Jangan taruh handler webhook di runtime edge
Ada naluri yang terus bertahan bahwa penerima webhook harus memakai runtime = "edge" karena harus cepat. Itu saran yang sudah usang.
Default Vercel adalah Node.js di Fluid Compute, yang memakai ulang instance untuk permintaan yang berjalan bersamaan dan sebagian besar sudah menutup selisih cold start yang dulu membuat edge menarik. Sementara itu, runtime edge membuat Anda kehilangan hal-hal nyata yang dibutuhkan handler webhook: node:crypto yang lengkap, API Node yang diandalkan sebagian besar SDK provider, dan jendela eksekusi yang lebih panjang. Respons streaming, alasan lain yang sering disebut orang, berjalan baik di Node.js, dan sudah begitu sejak lama.
Kegagalan konkretnya: SDK provider yang memanggil crypto.createHmac melempar error di edge, jadi Anda menulis verifikasi sendiri dengan WebCrypto, dan di versi buatan sendiri itulah perbandingan timing-safe diam-diam hilang. Anda menukar properti keamanan yang nyata dengan selisih cold start yang sebagian besar sudah tertutup.
Tetap pakai default.
Jika Anda sedang menatap handler yang jalan di lokal tetapi gagal di production, Men-debug Webhook yang Gagal di Production berisi pohon keputusan untuk mempersempit lapisan mana yang menelan permintaan.
3. waitUntil bukan antrean
Solusi alami untuk “balas cepat, kerjakan belakangan” di Vercel adalah waitUntil:
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const event = await verify(req);
waitUntil(syncToSalesforce(event)); // continues after the response
return new Response(null, { status: 200 });
}
Ini benar-benar berguna, dan ini benar-benar bukan persistensi. Perbedaannya lebih penting daripada kedengarannya:
- Jika instance di-recycle atau crash di tengah jalan, pekerjaannya hilang. Tidak ada percobaan ulang (retry), tidak ada dead-letter, tidak ada catatan
- Anda sudah membalas provider dengan 200. Provider tidak akan pernah mengirim ulang. Event itu kini tidak ada di mana pun
- Kegagalan di dalam
waitUntiltidak membuat permintaan gagal, jadi kecuali Anda secara eksplisit menangkap dan melaporkannya, kegagalan itu tidak terlihat
Profil kegagalannya adalah jenis yang terburuk: jarang, senyap, dan mustahil direkonstruksi setelah kejadian, karena satu-satunya salinan event ada di memori.
waitUntil adalah alat yang tepat untuk pekerjaan fire-and-forget yang boleh hilang: ping analitik, cache warming, notifikasi Slack. Ini alat yang salah untuk apa pun yang akan disadari pelanggan jika hilang.
Aturan yang menjauhkan Anda dari masalah adalah menyimpan sebelum mengonfirmasi penerimaan. Tulis event ke Postgres, Redis, atau antrean sungguhan, baru kemudian kembalikan 200, lalu proses. Sekarang 200 itu pernyataan yang jujur, karena event-nya memang sudah Anda pegang, dan pemrosesan boleh gagal lalu dicoba ulang sebanyak yang diperlukan.
export async function POST(req: Request) {
const event = await verify(req);
await db.insert(events).values({ id: event.id, payload: event }); // durable
waitUntil(processNow(event.id)); // fast path; a cron sweeps anything left behind
return new Response(null, { status: 200 });
}
Jalur cepat menangani kasus normal. Penyisiran cron menangani instance yang mati. Tidak satu pun dari keduanya kehilangan event.
4. Cold start ikut dihitung dalam batas waktu provider
Batas eksekusi default Vercel adalah 300 detik, yang terdengar seolah timeout bukan lagi masalah. Kenyataannya tidak, karena yang menghitung waktu Anda bukan Vercel. Yang menghitung waktu Anda adalah siapa pun yang memanggil Anda, dan batas waktunya jauh lebih ketat:
| Provider | Batas waktu respons |
|---|---|
| Slack, Discord | 3 detik |
| Shopify | 5 detik |
| GitHub | 10 detik |
| Twilio | 15 detik |
| Stripe | tidak dipublikasikan |
Timeout sebenarnya untuk handler Anda ditentukan oleh provider paling ketat yang mengirim ke Anda, jadi ada gunanya mengetahui setiap batas waktu yang Anda hadapi, bukan hanya yang Anda ingat. Referensi provider webhook kami mencantumkan semuanya. Dibandingkan lima detik dari Shopify, cold start 2 detik sudah menghabiskan 40% batas waktu Anda sebelum baris pertama Anda dijalankan. Pemeriksa timeout mengukur waktu dua permintaan ke route Anda, sehingga permintaan yang dingin tampil di samping yang hangat, dan jika bagian yang lambat adalah pemanggilan model, Handler Webhook Anda Memanggil LLM membahas after() dan batasnya.
Yang benar-benar membantu:
- Jaga agar graf import route tetap kecil. Route webhook yang hanya memverifikasi dan memasukkan ke antrean tidak seharusnya secara transitif meng-import ORM, SDK admin, dan klien email Anda. Masing-masing di-parse saat cold start
- Lakukan lazy import untuk modul yang berat di dalam cabang yang membutuhkannya, bukan di level modul
- Tetapkan
maxDurationyang rendah di route webhook.export const maxDuration = 10tidak membuat Anda lebih cepat, tetapi mengubah handler yang lepas kendali menjadi kegagalan cepat yang akan dicoba ulang provider, alih-alih hang 300 detik yang menghabiskan komputasi tanpa membawa Anda lebih dekat ke mana pun
5. Konkurensi bukan teman Anda pukul 3 dini hari
Serverless melakukan scale out, persis yang Anda inginkan saat flash sale, dan persis yang mematahkan asumsi yang mendasari sebagian besar kode idempotensi.
Seratus instance yang berjalan bersamaan berarti seratus handler yang berjalan bersamaan, masing-masing membuka koneksi database. Postgres punya batas koneksi, dan batas itu lebih rendah daripada perkiraan Anda. Untuk itulah connection pooling di platform tersedia, dan melewatkannya membuat lonjakan trafik berubah menjadi too many connections di setiap permintaan, termasuk permintaan dari pengguna sungguhan.
Yang lebih halus: dua percobaan ulang dari event yang sama bisa mendarat di dua instance pada milidetik yang sama. Logika idempotensi yang membaca lalu menulis punya race condition di situ yang tidak akan pernah tertangkap oleh tes berurutan. Yang harus mengerjakannya adalah unique constraint, bukan if:
// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);
// Safe: the database arbitrates
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[event.id],
);
if (rowCount === 0) return ok();
await doWork(event);
Pembahasan lebih lanjut, termasuk pengiriman yang tidak berurutan, ada di Anda Menerima Webhook yang Sama Dua Kali.
Peran AnyHook
Semua poin di atas, kecuali soal raw body, sebenarnya masalah yang sama: runtime yang hidup sebatas satu permintaan diminta membuat janji persistensi yang tidak bisa ditepatinya sendiri.
AnyHook memindahkan janji itu keluar dari fungsi. Provider mengirim POST ke in.anyhook.net/you/app, lalu:
- Respons 200 kembali dalam sekitar satu detik, dari edge worker yang tugasnya hanya menyimpan dan mengonfirmasi penerimaan. Lima detik dari Shopify tidak lagi menjadi batasan Anda
- Event tersimpan secara persisten sebelum kami mengonfirmasi penerimaannya, dengan enkripsi saat disimpan (at rest), sehingga “instance-nya mati” berarti percobaan ulang, bukan kehilangan data
- Pengiriman ke fungsi Vercel Anda dicoba ulang setelah 5 menit, 30 menit, 3 jam, 18 jam, lalu setiap hari, 3 sampai 10 upaya tergantung paket, dan apa pun yang masih gagal tetap ada di log untuk diputar ulang (replay)
- Satu header
AnyHook-Signaturemenggantikan skema verifikasi per provider, ditandatangani ulang di setiap upaya dengan stempel waktu baru, sehingga percobaan ulang tidak gagal di rentang toleransi seperti yang terjadi pada tanda tangan provider yang diteruskan - Putar ulang bisa mencakup satu rentang waktu, untuk saat jendela deploy menelan event selama satu jam
Handler Anda tetap harus idempoten. Tidak ada yang menghapus kebutuhan itu. Tetapi handler tidak lagi harus persisten, cepat, dan selalu hangat sekaligus.
Untuk mencobanya sebelum mendaftar apa pun, satu perintah mengembalikan URL masuk (inbound URL) yang aktif, tanpa akun:
curl -X POST https://anyhook.net/api/v1/quickstart
Responsnya berisi inbound_url untuk dipasang di provider, api_key untuk membaca log event lewat API, dan claim_url. Endpoint ini sementara: endpoint dan event-nya dihapus setelah 7 hari kecuali Anda membuka URL klaim dan masuk, dan dengan begitu URL-nya tetap sama.
Kesimpulan
Baca raw body lebih dulu, tetap di runtime Node.js, dan anggap waitUntil sebagai best-effort, bukan antrean. Jika ada satu gagasan struktural yang Anda ambil dari sini, jadikan ini: jangan pernah mengembalikan 200 untuk event yang belum Anda simpan secara persisten. Semua hal lain di daftar ini adalah masalah performa. Yang satu itu adalah masalah kehilangan data, dan hanya masalah itu yang tidak bisa Anda debug setelah kejadian.
Versi Workers dari daftar ini cukup berbeda sehingga kami menulisnya terpisah: Menerima Webhook di Cloudflare Workers.