Endpoint webhook Vercel harus memperlakukan setiap request sebagai pesan eksternal yang dapat dipalsukan, dikirim ulang, diterima lebih dari sekali, atau berubah format pada masa mendatang. Karena itu, kontraknya tidak cukup hanya berupa definisi JSON; kontrak juga mencakup signature, aturan replay, idempotensi, versi skema, kode status HTTP, dan perilaku retry.

Untuk Webhooks Vercel yang menggunakan mekanisme resmi Vercel, verifikasi dilakukan melalui signature berbasis Svix. Request perlu diverifikasi menggunakan raw body dan header Svix sebelum JSON diproses. Setelah autentikasi berhasil, gunakan svix-id sebagai salah satu kandidat kunci idempotensi, simpan status pemrosesan secara atomik, lalu kembalikan respons 2xx hanya ketika event sudah diterima secara aman.

1. Bentuk Kontrak Webhook yang Perlu Ditentukan

Kontrak webhook sebaiknya menjawab beberapa pertanyaan berikut:

  • Header apa yang digunakan untuk autentikasi dan identifikasi pesan?
  • Apakah body harus diverifikasi dalam bentuk byte atau teks mentah?
  • Berapa lama timestamp request dianggap valid?
  • Apa kunci idempotensi untuk mendeteksi pengiriman ulang?
  • Versi skema ditentukan oleh field apa?
  • Kapan endpoint mengembalikan 2xx, 4xx, atau 5xx?
  • Apakah pekerjaan berat dilakukan langsung atau dimasukkan ke queue?

Jangan menganggap bentuk payload sebagai satu-satunya kontrak. Payload dapat berubah ketika Vercel menambahkan field baru, memperkenalkan event baru, atau mengubah versi integrasi. Pisahkan verifikasi transport dari validasi isi event:

  1. Terima body mentah dan header signature.
  2. Verifikasi signature.
  3. Parse JSON setelah signature valid.
  4. Validasi tipe event dan versi skema.
  5. Simpan atau enqueue event secara idempoten.
  6. Proses event di worker jika pekerjaannya tidak ringan.

Jangan mengubah body sebelum verifikasi

Signature dihitung terhadap isi body tertentu. Parsing JSON lalu melakukan serialisasi ulang dapat mengubah whitespace, urutan, atau representasi nilai sehingga hasilnya berbeda dari body yang ditandatangani. Karena itu, gunakan request.text() atau API yang setara untuk memperoleh body mentah. Jangan memanggil parser JSON lebih dahulu.

2. Verifikasi Signature Webhook Vercel dengan Mekanisme Resmi

Webhooks Vercel menggunakan mekanisme signing dari Svix. Header yang umum digunakan adalah:

  • svix-id: identifier unik untuk pesan.
  • svix-timestamp: timestamp yang ikut ditandatangani.
  • svix-signature: satu atau beberapa nilai signature.

Gunakan secret webhook yang diberikan oleh konfigurasi webhook Vercel dan library resmi atau library Svix yang sesuai dengan runtime Anda. Hindari membuat algoritma verifikasi sendiri jika library resmi tersedia, karena format signature, beberapa signature, dan validasi timestamp mudah salah diimplementasikan.

Contoh endpoint Node.js pada Vercel

Contoh berikut menggunakan Web API Request, sehingga pola ini dapat diadaptasi ke route handler atau function yang mendukung API tersebut. Nama environment variable dan repository dapat disesuaikan dengan aplikasi Anda.

import { Webhook } from "svix";

const webhookSecret = process.env.VERCEL_WEBHOOK_SECRET;

export async function POST(request) {
  if (!webhookSecret) {
    console.error("VERCEL_WEBHOOK_SECRET belum dikonfigurasi");
    return new Response("Server configuration error", { status: 500 });
  }

  // Ambil body mentah. Jangan parse JSON sebelum verify().
  const rawBody = await request.text();
  const headers = {
    "svix-id": request.headers.get("svix-id") ?? "",
    "svix-timestamp": request.headers.get("svix-timestamp") ?? "",
    "svix-signature": request.headers.get("svix-signature") ?? ""
  };

  let event;
  try {
    const wh = new Webhook(webhookSecret);
    event = wh.verify(rawBody, headers);
  } catch (error) {
    console.warn("Signature webhook tidak valid", {
      svixId: headers["svix-id"] || undefined
    });
    return new Response("Invalid webhook signature", { status: 400 });
  }

  const eventId = headers["svix-id"];

  // Operasi ini harus atomik dan memiliki unique constraint pada eventId.
  const accepted = await saveIfNew({
    eventId,
    rawBody,
    event
  });

  if (!accepted) {
    // Duplikasi dianggap sudah diterima, bukan kegagalan.
    return new Response("Already processed", { status: 200 });
  }

  // Untuk pekerjaan berat, enqueue setelah event tersimpan.
  await enqueueWebhookEvent({ eventId, event });

  return new Response("Accepted", { status: 202 });
}

Implementasi produksi harus memeriksa bahwa semua header wajib tersedia. Library verifikasi menangani detail validasi signature dan timestamp sesuai mekanisme Svix. Secret harus disimpan sebagai environment variable terenkripsi atau secret manager, bukan di source code, log, atau fixture yang ikut dipublikasikan.

Catatan: Pastikan versi package Svix yang digunakan kompatibel dengan runtime function Anda. Detail import dan dukungan runtime dapat berbeda antarversi, sehingga dokumentasi resmi package tetap menjadi acuan integrasi.

3. Perlindungan Replay dan Pengiriman Ulang

Replay attack terjadi ketika request valid yang pernah ditangkap dikirim kembali untuk kedua kalinya. Signature yang valid tidak otomatis berarti request tersebut baru. Ada dua lapisan perlindungan yang sebaiknya digunakan:

Validasi timestamp

Timestamp pada header Svix ikut ditandatangani. Library verifikasi Svix melakukan pemeriksaan timestamp untuk mencegah pesan yang terlalu lama digunakan kembali. Gunakan toleransi waktu yang wajar sesuai kebijakan sistem dan dokumentasi library yang dipakai. Jangan menonaktifkan pemeriksaan timestamp kecuali ada alasan operasional yang terdokumentasi.

Timestamp bukan pengganti idempotensi. Request yang sama masih dapat diterima dua kali dalam jendela waktu yang valid, misalnya ketika provider melakukan retry beberapa detik setelah request pertama.

Deduplicasi berdasarkan event ID

Simpan svix-id atau identifier event yang dijamin unik oleh provider dalam tabel dengan unique constraint. Jangan hanya memeriksa keberadaan record lalu melakukan insert dalam dua operasi terpisah tanpa transaksi, karena dua request paralel dapat sama-sama lolos pemeriksaan.

CREATE TABLE webhook_receipts (
  event_id TEXT PRIMARY KEY,
  received_at TIMESTAMP NOT NULL,
  status TEXT NOT NULL,
  payload_hash TEXT,
  processed_at TIMESTAMP NULL,
  last_error TEXT NULL
);

Operasi saveIfNew harus memakai mekanisme seperti INSERT ... ON CONFLICT DO NOTHING atau fitur equivalent pada database. Jika identifier dari payload aplikasi juga tersedia, simpan sebagai metadata, tetapi gunakan identifier transport yang stabil untuk deduplikasi request.

4. Idempotensi pada Pemrosesan Event

Deduplicating request hanya mencegah sebagian duplikasi. Worker juga harus idempoten karena proses dapat gagal setelah efek samping dilakukan tetapi sebelum status selesai tersimpan. Contohnya, pembayaran berhasil dibuat lalu worker crash sebelum menandai event sebagai selesai.

Gunakan salah satu atau kombinasi strategi berikut:

  • Gunakan event ID sebagai idempotency key pada operasi downstream jika layanan tujuan mendukungnya.
  • Simpan status event dan efek samping dalam transaksi database yang sama bila memungkinkan.
  • Gunakan unique constraint pada resource bisnis, bukan hanya pada tabel receipt.
  • Gunakan pola inbox untuk menerima event dan outbox untuk menerbitkan efek samping secara andal.
  • Simpan status seperti received, processing, processed, dan failed dengan aturan retry yang jelas.

Jangan menganggap respons 202 Accepted berarti pekerjaan sudah selesai. Status tersebut berarti request sudah diterima untuk diproses. Jika event sudah durable di database atau queue, respons 202 cocok untuk pekerjaan asinkron.

5. Versi Skema dan Evolusi Payload

Validasi signature hanya membuktikan bahwa body berasal dari sumber yang memiliki secret. Validasi tersebut tidak membuktikan bahwa payload sesuai dengan skema aplikasi Anda. Tambahkan strategi versi skema yang eksplisit pada kontrak internal atau adapter event.

Gunakan adapter berdasarkan versi

Jika payload memiliki field versi, baca field tersebut setelah signature valid. Nama field yang benar bergantung pada kontrak webhook yang digunakan; jangan mengasumsikan semua event Vercel mempunyai struktur yang sama. Untuk event yang tidak menyediakan versi aplikasi yang Anda perlukan, simpan payload mentah dan gunakan adapter berdasarkan tipe event atau versi integrasi yang tercatat di konfigurasi.

function normalizeEvent(event) {
  const eventType = event.type;
  const schemaVersion = event.schema_version ?? "1";

  if (eventType === "deployment" && schemaVersion === "1") {
    return {
      kind: "deployment",
      version: 1,
      externalId: event.id,
      data: event.data
    };
  }

  throw new Error(`Unsupported webhook schema: ${eventType}/${schemaVersion}`);
}

Perubahan yang kompatibel, seperti menambahkan field opsional, sebaiknya tidak merusak consumer lama. Perubahan yang tidak kompatibel, seperti mengganti tipe field atau menghapus field wajib, perlu versi baru atau adapter baru. Simpan raw payload untuk debugging dan migrasi, tetapi jangan menjadikan parsing bebas tanpa validasi sebagai kontrak permanen.

6. Kode Status HTTP, Retry, dan Queue

Provider webhook biasanya menganggap respons 2xx sebagai penerimaan yang berhasil. Kode non-2xx dapat memicu retry sesuai kebijakan provider. Terapkan aturan berikut:

  • 2xx: signature valid dan event sudah tersimpan atau berhasil dimasukkan ke queue. Duplikasi yang sudah diproses juga dapat dijawab 200.
  • 400: body rusak, header wajib tidak ada, signature tidak valid, atau skema memang tidak didukung secara permanen.
  • 401/403: dapat digunakan untuk kebijakan autentikasi tertentu, tetapi pahami bahwa provider mungkin melakukan retry terhadap respons non-2xx.
  • 429: gunakan hanya jika memang ingin meminta pengiriman ulang karena throttling.
  • 500 atau 503: gunakan ketika kegagalan bersifat sementara, misalnya database atau queue sedang tidak tersedia.

Jangan mengembalikan 2xx jika signature valid tetapi event gagal disimpan. Sebaliknya, jika event sudah tersimpan dan hanya worker yang gagal, kembalikan 2xx lalu biarkan queue melakukan retry. Retry sebaiknya menggunakan backoff, batas percobaan, dead-letter queue, dan alarm untuk event yang terus gagal.

7. Logging dan Observability tanpa Membocorkan Secret

Log yang berguna tidak harus memuat secret atau seluruh payload. Catat metadata berikut:

  • svix-id untuk korelasi dan deduplikasi.
  • Tipe event dan versi skema setelah payload diverifikasi.
  • Waktu penerimaan, durasi verifikasi, dan durasi enqueue.
  • Status pemrosesan dan jumlah retry.
  • Correlation ID untuk trace lintas service.

Hindari mencatat VERCEL_WEBHOOK_SECRET, header signature lengkap, token, cookie, atau payload mentah yang mengandung data pribadi. Jika perlu debugging, simpan hash body atau subset field yang sudah disensor. Akses ke raw payload di database juga harus dibatasi dan memiliki retensi yang jelas.

8. Pengujian dengan Fixture dan Simulasi Duplikasi

Buat fixture payload yang merepresentasikan event nyata, tetapi gunakan secret khusus pengujian dan data sintetis. Uji setidaknya skenario berikut:

  • Signature valid dengan body yang tidak berubah.
  • Body diubah satu karakter setelah signature dibuat.
  • Header svix-id, timestamp, atau signature hilang.
  • Timestamp terlalu lama atau formatnya tidak valid.
  • Request yang sama dikirim dua kali secara berurutan.
  • Dua request duplikat diproses secara paralel.
  • Event dengan versi skema yang didukung.
  • Event dengan versi atau tipe yang belum didukung.
  • Database gagal setelah request diterima.
  • Queue gagal setelah receipt berhasil disimpan.

Untuk simulasi duplikasi, kirim fixture dengan svix-id yang sama dua kali dan pastikan hanya satu efek samping bisnis terjadi. Test paralel lebih penting daripada test berurutan karena race condition sering tidak terlihat pada pengujian sederhana.

9. Checklist Produksi

  • Secret webhook disimpan di environment variable atau secret manager.
  • Body mentah diverifikasi sebelum parsing JSON.
  • Library mekanisme resmi Vercel/Svix digunakan, bukan implementasi signature buatan sendiri.
  • Header svix-id, svix-timestamp, dan svix-signature divalidasi.
  • Validasi timestamp aktif untuk membatasi replay.
  • Event ID memiliki unique constraint dan insert dilakukan secara atomik.
  • Efek samping bisnis idempoten, termasuk ketika worker crash atau retry.
  • Raw payload disimpan dengan retensi dan kontrol akses yang sesuai.
  • Versi skema dan tipe event ditangani secara eksplisit.
  • Respons 2xx hanya diberikan setelah event durable tersimpan atau masuk queue.
  • Kegagalan sementara dibedakan dari error permanen agar retry tidak sia-sia.
  • Metric, log terstruktur, alert, dan dead-letter handling tersedia.
  • Fixture pengujian mencakup signature invalid, replay, duplikasi paralel, dan perubahan skema.

Dengan kontrak seperti ini, endpoint webhook Vercel tidak hanya memeriksa apakah request terlihat valid. Endpoint juga menentukan apakah pesan masih relevan, apakah sudah pernah diterima, apakah bentuknya didukung, dan apakah pemrosesan dapat diulang tanpa menggandakan efek samping.