Rekayasa3 Agustus 20269 mnt baca

Menerima Webhook di Vercel tanpa Ada yang Hilang

Verifikasi signature yang gagal tanpa sebab, pekerjaan waitUntil yang lenyap, cold start yang memakan batas timeout: jebakan serverless dan solusinya.

waitUntil · 200 ITU JANJI YANG TIDAK BISA ANDA TEPATIverifikasikembalikan 200waitUntil(work)hanya di memorididaur ulang → hilang, tidak pernah dikirim ulangSIMPAN DULU · 200 ITU BENARverifikasitulis persistenkembalikan 200prosesbisa dicoba ulang
Perbedaan antara 200 yang benar dan 200 yang sekadar harapan.

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
  • waitUntil memperpanjang 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.

HMAC DIHITUNG DARI BYTE, BUKAN DARI MAKNAbyte dari jaringan{"id":"evt_1","n":2}req.text()hmaccocokparse + stringify ulang{ "n": 2, "id": "evt_1" }req.json()hmactidak cocokdatanya samadatanya sama. urutan key berbeda,spasi berbeda, dan payload-nyatampak sempurna di log Anda.

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 waitUntil tidak 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.

waitUntil · 200 ITU JANJI YANG TIDAK BISA ANDA TEPATIverifikasikembalikan 200waitUntil(work)hanya di memorididaur ulang → hilang, tidak pernah dikirim ulangSIMPAN DULU · 200 ITU BENARverifikasitulis persistenkembalikan 200prosesbisa dicoba ulang

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:

ProviderBatas waktu respons
Slack, Discord3 detik
Shopify5 detik
GitHub10 detik
Twilio15 detik
Stripetidak 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 maxDuration yang rendah di route webhook. export const maxDuration = 10 tidak 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-Signature menggantikan 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.

Semua artikel3 Agustus 2026 · 9 mnt

Berhenti kehilangan webhook.

Ganti satu URL. Dapatkan percobaan ulang, log event, dan putar ulang sekali klik. Atau lihat dulu: daftar dan jalankan latihan gangguan 30 detik, lalu saksikan event tetap selamat saat server Anda mati. Tidak perlu server.