Endpoint webhook yang andal tidak cukup hanya menerima POST dan memproses JSON. Dalam integrasi nyata, pengirim webhook sering melakukan retry saat timeout, mengirim ulang event yang sama, atau bahkan mengirim event tidak berurutan. Karena itu, SvelteKit: kontrak webhook yang tahan retry dan event duplikat perlu dirancang sejak awal di level protokol, bukan ditambal setelah error muncul di produksi.

Pendekatan yang paling aman adalah: verifikasi signature dari raw body, batasi umur timestamp untuk mencegah replay, simpan event ID untuk deduplikasi, dan pisahkan penerimaan request dari pemrosesan bisnis. Endpoint sebaiknya cepat membalas status yang tepat, lalu proses berat dijalankan secara asinkron. Dengan pola ini, retry dari provider tidak menyebabkan side effect ganda.

Kontrak webhook yang perlu disepakati

Jika Anda mengontrol kedua sisi integrasi, jangan mulai dari payload dulu. Mulailah dari kontrak yang menjawab pertanyaan operasional: bagaimana memverifikasi keaslian request, bagaimana membedakan event baru dan event duplikat, dan kapan pengirim harus retry.

Struktur minimum payload

Payload tidak perlu rumit, tetapi harus cukup untuk identifikasi, versioning, dan pemrosesan ulang.

{
  "id": "evt_01JXYZ...",
  "type": "invoice.paid",
  "occurred_at": "2026-06-16T10:15:30Z",
  "source": "billing-service",
  "data": {
    "invoice_id": "inv_123",
    "customer_id": "cus_456",
    "amount": 125000,
    "currency": "IDR",
    "status": "paid"
  },
  "schema_version": "2026-01-01"
}

Field yang sebaiknya selalu ada:

  • id: identifier unik event, dipakai untuk deduplikasi.
  • type: nama event stabil, misalnya invoice.paid.
  • occurred_at: kapan event terjadi di sistem sumber, bukan kapan dikirim.
  • source: identitas sistem pengirim.
  • data: payload domain.
  • schema_version: membantu kompatibilitas saat payload berkembang.

Jangan mengandalkan hash dari seluruh payload sebagai pengganti event id. Perubahan kecil yang tidak relevan, seperti urutan field JSON, bisa merusak deduplikasi bila serialisasi berbeda.

Header yang penting

Selain payload, kontrak sebaiknya memakai header eksplisit untuk keamanan dan troubleshooting:

  • X-Webhook-Event-Id: boleh redundan dengan field id, memudahkan logging cepat.
  • X-Webhook-Timestamp: UNIX epoch atau ISO timestamp yang ditandatangani.
  • X-Webhook-Signature: HMAC dari raw body dan metadata tertentu.
  • X-Webhook-Source: identitas pengirim jika dibutuhkan.

Kalau Anda tidak mendesain provider-nya, ikuti header yang memang dikirim provider tersebut. Prinsipnya tetap sama: verifikasi harus dilakukan terhadap representasi data yang benar-benar diterima.

Aturan status code: kapan 2xx, 4xx, dan 5xx

Salah satu sumber bug terbesar pada webhook adalah pemilihan status code yang salah. Status code adalah sinyal kontrak ke pengirim: retry atau jangan retry.

Balas 2xx jika request valid dan sudah diterima untuk diproses

Gunakan 200, 202, atau 204 bila:

  • signature valid,
  • timestamp masih dalam toleransi,
  • payload lolos validasi kontrak minimum,
  • event berhasil dicatat ke penyimpanan/queue untuk diproses.

202 Accepted sering paling jujur secara semantik jika pemrosesan bisnis dilakukan asinkron. 200/204 juga umum dipakai bila provider hanya butuh sinyal sukses sederhana.

Untuk event duplikat yang sudah pernah diproses atau minimal sudah pernah diterima, tetap aman membalas 2xx. Duplikat bukan error protokol; itu kondisi yang harus diantisipasi.

Balas 4xx jika masalah ada pada request dan retry tidak akan membantu

  • 400 Bad Request: JSON rusak, field wajib hilang, format timestamp tidak valid.
  • 401/403: signature tidak valid, secret salah, atau request tidak terotorisasi.
  • 415 Unsupported Media Type: jika Anda mewajibkan application/json dan menerima format lain.
  • 422 Unprocessable Entity: struktur valid tetapi isi domain tidak bisa diterima menurut kontrak.

4xx memberi sinyal bahwa pengirim perlu memperbaiki request, bukan sekadar mencoba ulang.

Balas 5xx jika sistem Anda gagal memproses penerimaan secara sementara

Gunakan 500, 502, 503, atau 504 saat:

  • database deduplikasi tidak tersedia,
  • queue tidak bisa diakses,
  • penyimpanan event gagal,
  • dependensi internal error sementara.

Jika Anda belum bisa menjamin event tercatat secara aman, lebih baik balas 5xx agar pengirim melakukan retry daripada membalas sukses dan kehilangan event.

Aturan praktis: jangan membalas 2xx sebelum Anda punya bukti event sudah diterima dengan aman, misalnya tersimpan di tabel inbox atau berhasil dimasukkan ke queue persisten.

Verifikasi signature dan timestamp di SvelteKit

Pada banyak implementasi webhook, bug muncul karena body dibaca sebagai JSON dulu, lalu string hasil parse di-hash ulang. Itu salah untuk skema signature yang mensyaratkan raw body. HMAC harus dihitung dari bytes yang sama seperti yang dikirim provider.

Contoh endpoint +server.ts

import { createHmac, timingSafeEqual } from 'node:crypto';
import { json, type RequestHandler } from '@sveltejs/kit';

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
const TIMESTAMP_TOLERANCE_SECONDS = 300;

function computeSignature(payload: string, timestamp: string, secret: string) {
  const signed = `${timestamp}.${payload}`;
  return createHmac('sha256', secret).update(signed).digest('hex');
}

function safeCompareHex(a: string, b: string) {
  try {
    const aBuf = Buffer.from(a, 'hex');
    const bBuf = Buffer.from(b, 'hex');
    if (aBuf.length !== bBuf.length) return false;
    return timingSafeEqual(aBuf, bBuf);
  } catch {
    return false;
  }
}

function isTimestampFresh(timestampHeader: string, toleranceSeconds: number) {
  const ts = Number(timestampHeader);
  if (!Number.isFinite(ts)) return false;
  const now = Math.floor(Date.now() / 1000);
  return Math.abs(now - ts) <= toleranceSeconds;
}

export const POST: RequestHandler = async ({ request, locals }) => {
  const contentType = request.headers.get('content-type') ?? '';
  if (!contentType.includes('application/json')) {
    return json({ error: 'unsupported_media_type' }, { status: 415 });
  }

  const signature = request.headers.get('x-webhook-signature');
  const timestamp = request.headers.get('x-webhook-timestamp');
  const eventIdHeader = request.headers.get('x-webhook-event-id');

  if (!signature || !timestamp) {
    return json({ error: 'missing_signature_headers' }, { status: 400 });
  }

  if (!isTimestampFresh(timestamp, TIMESTAMP_TOLERANCE_SECONDS)) {
    return json({ error: 'stale_or_invalid_timestamp' }, { status: 401 });
  }

  const rawBody = await request.text();
  const expected = computeSignature(rawBody, timestamp, WEBHOOK_SECRET);

  if (!safeCompareHex(signature, expected)) {
    return json({ error: 'invalid_signature' }, { status: 401 });
  }

  let payload: {
    id: string;
    type: string;
    occurred_at: string;
    source?: string;
    data: Record<string, unknown>;
    schema_version?: string;
  };

  try {
    payload = JSON.parse(rawBody);
  } catch {
    return json({ error: 'invalid_json' }, { status: 400 });
  }

  if (!payload?.id || !payload?.type || !payload?.occurred_at || !payload?.data) {
    return json({ error: 'invalid_payload' }, { status: 422 });
  }

  if (eventIdHeader && eventIdHeader !== payload.id) {
    return json({ error: 'event_id_mismatch' }, { status: 400 });
  }

  try {
    const accepted = await locals.webhookInbox.accept({
      eventId: payload.id,
      eventType: payload.type,
      occurredAt: payload.occurred_at,
      rawBody,
      headers: {
        signature,
        timestamp,
        eventId: eventIdHeader ?? payload.id
      }
    });

    if (!accepted.inserted) {
      return json({ ok: true, duplicate: true }, { status: 202 });
    }

    return json({ ok: true }, { status: 202 });
  } catch (error) {
    console.error('webhook_accept_failed', { error, eventId: payload.id });
    return json({ error: 'temporary_failure' }, { status: 503 });
  }
};

Mengapa urutannya seperti itu?

  • Header dicek lebih dulu untuk menolak request yang jelas tidak valid.
  • Timestamp diverifikasi sebelum parse JSON agar request usang ditolak lebih cepat.
  • Raw body dibaca dengan request.text() supaya signature cocok dengan body asli.
  • JSON di-parse setelah signature valid untuk mencegah memproses data tak tepercaya.
  • Event disimpan dulu sebelum membalas 2xx, agar retry dari provider tidak menyebabkan kehilangan event.

Toleransi timestamp dan replay attack

Timestamp tolerance mencegah request lama diputar ulang oleh pihak yang berhasil menangkap traffic atau log. Nilai 5 menit sering cukup sebagai titik awal, tetapi trade-off-nya:

  • Terlalu pendek: request sah bisa ditolak bila jam server berbeda atau antrean jaringan terlambat.
  • Terlalu longgar: jendela replay makin besar.

Pastikan sinkronisasi waktu server cukup baik. Jika lingkungan Anda rawan clock drift, observability untuk selisih waktu request sangat membantu sebelum mengubah toleransi.

Pola idempotent consumer dengan penyimpanan event ID

Retry adalah normal. Karena itu, handler webhook harus idempotent: event yang sama boleh datang berkali-kali, tetapi side effect hanya terjadi sekali.

Prinsip dasar

  1. Identifikasi event dengan event ID yang stabil.
  2. Simpan event ID ke tabel atau store deduplikasi dengan constraint unik.
  3. Jika insert gagal karena duplikat, anggap request sudah pernah diterima.
  4. Jalankan side effect bisnis berdasarkan event yang telah diterima, idealnya di worker terpisah.

Pseudo-code penyimpanan deduplikasi

type AcceptResult = {
  inserted: boolean;
  status: 'received' | 'duplicate';
};

async function acceptWebhookEvent(db, input): Promise<AcceptResult> {
  // Tabel webhook_inbox memiliki unique index pada provider + event_id
  // Kolom umum: provider, event_id, event_type, occurred_at, received_at,
  // raw_body, headers_json, processing_status

  try {
    await db.insert('webhook_inbox', {
      provider: 'billing-service',
      event_id: input.eventId,
      event_type: input.eventType,
      occurred_at: input.occurredAt,
      received_at: new Date().toISOString(),
      raw_body: input.rawBody,
      headers_json: JSON.stringify(input.headers),
      processing_status: 'pending'
    });

    await db.enqueue('webhook-jobs', {
      provider: 'billing-service',
      eventId: input.eventId
    });

    return { inserted: true, status: 'received' };
  } catch (err) {
    if (isUniqueViolation(err)) {
      return { inserted: false, status: 'duplicate' };
    }
    throw err;
  }
}

Dengan pola ini, idempotensi tidak bergantung pada memori proses atau cache lokal. Itu penting karena instance aplikasi bisa lebih dari satu, dan request retry bisa mendarat ke node berbeda.

Database vs Redis untuk deduplikasi

Keduanya bisa dipakai, tetapi pemilihannya tergantung kebutuhan:

  • Database dengan unique constraint: paling sederhana dan kuat untuk jejak audit serta konsistensi.
  • Redis: berguna untuk deduplikasi cepat dengan TTL, tetapi kurang ideal jika Anda perlu histori permanen atau audit penuh.

Untuk webhook bisnis seperti pembayaran, database persisten biasanya pilihan lebih aman. Redis cocok sebagai lapisan tambahan, bukan satu-satunya sumber kebenaran.

Menangani event duplikat dan out-of-order event

Duplikat identik vs duplikat dengan payload berbeda

Kasus yang sering terlewat: provider bisa mengirim event ID yang sama tetapi isi payload berbeda karena bug atau perubahan internal. Untuk itu:

  • Simpan raw body asli.
  • Opsional, simpan hash payload untuk inspeksi cepat.
  • Jika event ID yang sama datang lagi dengan body berbeda, tandai sebagai anomali dan kirim alert.

Secara kontrak, event ID yang sama seharusnya mewakili event yang sama. Bila tidak, jangan diam-diam memproses ulang tanpa investigasi.

Out-of-order event

Webhook tidak selalu datang sesuai urutan terjadinya. Misalnya invoice.updated bisa tiba lebih dulu daripada invoice.created, atau event lama tertunda lalu masuk setelah state sudah berubah.

Strategi yang umum:

  1. Gunakan versi/urutan resource bila provider menyediakannya, misalnya version atau sequence.
  2. Bandingkan occurred_at dengan state terakhir yang sudah diproses.
  3. Terapkan update berbasis state monotonic, misalnya jangan menurunkan status dari paid ke pending hanya karena event lama datang belakangan.
  4. Jika state tidak cukup dari event, lakukan fetch ke API sumber untuk rekonsiliasi.

Contoh kebijakan state

function shouldApplyInvoiceEvent(currentState, incomingEvent) {
  const terminalStates = new Set(['paid', 'void', 'refunded']);

  if (!currentState) return true;

  // Jangan terapkan event yang lebih tua bila state sekarang sudah lebih baru
  if (new Date(incomingEvent.occurred_at) < new Date(currentState.last_event_at)) {
    return false;
  }

  // Contoh aturan domain: status terminal tidak boleh mundur ke status non-terminal
  if (terminalStates.has(currentState.status) && !terminalStates.has(incomingEvent.data.status)) {
    return false;
  }

  return true;
}

Penting: idempotensi dan ordering adalah dua masalah berbeda. Menyimpan event ID menyelesaikan duplikat, tetapi tidak otomatis menyelesaikan event yang datang tidak berurutan.

Observability untuk debugging integrasi webhook

Saat integrasi bermasalah, log sederhana seperti “webhook failed” hampir tidak berguna. Anda butuh jejak yang bisa menghubungkan request masuk, keputusan validasi, hasil deduplikasi, dan side effect yang terjadi.

Apa yang sebaiknya dicatat

  • request id internal untuk setiap request masuk,
  • event id, event type, dan source,
  • hasil verifikasi signature: valid/tidak, tanpa mencatat secret,
  • selisih waktu timestamp request terhadap waktu server,
  • hasil deduplikasi: baru atau duplikat,
  • hasil enqueue/penyimpanan inbox,
  • status pemrosesan worker: pending, processing, processed, failed,
  • error class dan kategori: validasi, auth, DB, queue, domain.

Metrik yang berguna

  • jumlah request per event type,
  • rasio signature invalid,
  • rasio duplicate event,
  • latensi endpoint webhook,
  • jumlah event yang gagal diproses di worker,
  • umur antrean sampai event benar-benar diproses.

Jika Anda memakai tracing, propagasikan event_id sebagai atribut span. Ini sangat membantu saat satu event memicu beberapa operasi downstream.

Redaksi data sensitif

Jangan log payload penuh secara sembrono bila data berisi informasi sensitif. Pilihan aman:

  • log field penting saja,
  • masking identifier tertentu,
  • simpan raw body terenkripsi di storage terbatas aksesnya,
  • pisahkan log aplikasi umum dari audit store webhook.

Kesalahan umum pada endpoint webhook SvelteKit

  • Mem-parse JSON sebelum verifikasi signature, lalu signature selalu gagal atau lebih buruk: data tak tepercaya sempat diproses.
  • Menganggap webhook pasti sekali kirim, padahal retry adalah perilaku normal.
  • Membalas 200 terlalu cepat sebelum event benar-benar tersimpan.
  • Mengandalkan cache/memori lokal untuk deduplikasi pada deployment multi-instance.
  • Tidak menyimpan raw body, sehingga sulit audit jika ada dispute integrasi.
  • Tidak punya kebijakan out-of-order, lalu state resource bisa mundur.
  • Tidak menetapkan timeout internal sehingga endpoint melambat dan memicu retry tambahan dari provider.

Checklist production untuk kontrak webhook

  • Payload memiliki id, type, occurred_at, data, dan versi skema.
  • Request diverifikasi dengan signature berbasis raw body.
  • Ada header timestamp dan toleransi replay yang jelas.
  • Secret dikelola aman dan bisa dirotasi.
  • Endpoint hanya membalas 2xx setelah event tersimpan aman.
  • Deduplikasi memakai store persisten dengan unique constraint.
  • Pemrosesan berat dipindahkan ke queue/worker.
  • Event duplikat dibalas 2xx, bukan dianggap error.
  • Ada kebijakan eksplisit untuk event out-of-order.
  • Raw body dan metadata request disimpan untuk audit/debugging.
  • Log dan metrik memuat event ID, hasil signature, dan hasil deduplikasi.
  • Payload sensitif dimasking atau disimpan terenkripsi sesuai kebutuhan.
  • Ada pengujian untuk skenario retry, duplicate, invalid signature, dan stale timestamp.

Penutup

Merancang SvelteKit: kontrak webhook yang tahan retry dan event duplikat berarti memperlakukan webhook sebagai protokol yang tidak selalu rapi: request bisa terlambat, diulang, atau datang tak berurutan. Solusi yang tahan produksi biasanya sederhana secara prinsip: verifikasi raw body, batasi replay dengan timestamp, simpan event ID untuk idempotensi, dan pisahkan penerimaan dari pemrosesan bisnis.

Kalau Anda hanya mengambil satu aturan dari artikel ini, ambil yang ini: anggap semua webhook bisa dikirim ulang kapan saja, dan rancang endpoint agar side effect tetap tepat satu kali. Dari situ, keputusan lain seperti status code, inbox table, dan observability akan mengikuti dengan lebih konsisten.