Pengenalan Cepat

Untuk menjaga integritas webhook, API route Next.js harus menegakkan kontrak payload, memverifikasi tanda tangan, dan mendukung idempoten serta retry. Artikel ini langsung menunjukkan bagaimana mendesain kontrak JSON, mengamankan auth, menyimpan idempotency key, dan menambahkan observability agar duplikasi bisa terdeteksi.

1. Kontrak JSON untuk Webhook yang Konsisten

Untuk memastikan API bisa menangani retry tanpa ambiguitas, definisikan struktur payload yang tetap, misalnya:

{
  "event": "payment.succeeded",
  "resource_id": "txn_123",
  "timestamp": "2024-10-10T08:32:00Z",
  "data": {"amount": 12000, "currency": "IDR"},
  "idempotency_key": "evt-abc-123"
}

Gunakan event dan resource_id sebagai bagian kontrak yang tidak boleh berubah, serta sertakan idempotency_key yang unik dari pengirim webhook. Validasi tipe data (string, ISO timestamp, numeric) di awal handler agar failure ada sebelum side effect.

Jika payload tidak sesuai, kirimkan 400 Bad Request dengan detail field yang gagal:

{"error": "invalid_payload", "details": "data.amount harus angka"}

Jangan lakukan perubahan state sebelum validasi selesai.

2. Auth dan Verifikasi Tanda Tangan

Untuk mencegah spoofing, webhook harus menyertakan header seperti X-Signature. Di Next.js App Router, periksa signature sebelum memproses payload:

import { NextResponse } from 'next/server';
import { createHmac } from 'crypto';

export async function POST(req) {
  const raw = await req.text();
  const signature = req.headers.get('x-signature');
  const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw)
    .digest('hex');

  if (!signature || expected !== signature) {
    return NextResponse.json({error: 'unauthorized'}, {status: 401});
  }

  const payload = JSON.parse(raw);
  // lanjutkan ke validasi payload
}

Gunakan raw body agar signature dihitung atas payload persis. Simpan WEBHOOK_SECRET di environment variable dan jangan log secret. Jika signature gagal, kirim 401 Unauthorized untuk menghentikan retry berlebihan.

3. Menyimpan dan Menggunakan Idempotency Key

Setelah signature dan struktur valid, simpan idempotency_key dengan status yang bisa dicek ulang sebelum mengeksekusi side effect. Contoh strategi:

  • Simpan record di tabel database webhook_requests dengan kolom idempotency_key, status, payload_hash, dan processed_at.
  • Gunakan transaksi untuk menulis record awal dengan status pending. Jika key sudah ada, periksa status—jika completed bisa langsung return 200 OK tanpa memproses ulang.
  • Jika proses gagal, catat status error agar retry berikutnya dapat mencoba ulang.

Contoh logika cek:

const existing = await db.webhookRequests.findUnique({
  where: {idempotency_key: payload.idempotency_key}
});
if (existing) {
  if (existing.status === 'completed') {
    return NextResponse.json({ok: true, duplicate: true});
  }
  // opsi: tunggu atau kirim kembali untuk retried in progress
}
await db.webhookRequests.create({
  data: {idempotency_key: payload.idempotency_key, status: 'processing'}
});

Jangan menjalankan side effect (kirim email, update saldo) sebelum entry dibuat agar retry tidak melewatkan pengecekan.

4. State versus Side Effect

Pisahkan mutasi state dengan aksi berdampak luar (eg. kirim notifikasi). Setelah idempotency key berhasil dicatat, jalankan operasi state (update basis data) lalu lakukan side effect terakhir. Dengan urutan ini, jika side effect gagal, Anda masih bisa mengetahui state dan memutuskan apakah retry perlu dilanjutkan.

Jika side effect tidak dapat di-rollback, rekomendasikan konsumer webhook menunggu dan retry karena API sudah jamin idempoten, atau buat mekanisme compensation (rollback manual) sebelum mengakui pengiriman.

5. Observability: Log dan Metrics untuk Duplikasi

Observability membantu mendeteksi request duplikat dan kegagalan retry. Implementasi minimal:

  • Log entry ketika payload diterima, signature diverifikasi, dan saat idempotency key sudah ada. Sertakan event, resource_id, dan idempotency_key.
  • Gunakan metric counter (misalnya via Prometheus) untuk webhook_duplicate dan webhook_processing_error.
  • Catat latency proses agar bisa memonitor backlog retry.

Contoh log message:

logger.info('webhook.received', {event: payload.event, key: payload.idempotency_key});
logger.warn('webhook.duplicate', {key: payload.idempotency_key});

Jika log menunjukkan banyak duplikat, pertimbangkan untuk menyesuaikan TTL atau retry interval di sisi pengirim.

6. Ringkasan Respons dan Tips Debug

Pastikan response API konsisten:

  • 200 OK dengan body {"ok": true, "duplicate": false} saat status baru selesai.
  • 200 OK dengan duplicate: true untuk request yang sudah diproses.
  • 400 Bad Request dengan penjelasan validasi field.
  • 401 Unauthorized jika tanda tangan tidak cocok.

Untuk debugging, rekam request_id dan idempotency key. Jika retry stuck, periksa database entry status dan gunakan trace ID di log untuk mengikuti perjalanan request.

Dengan pendekatan ini, handler Next.js mampu bertahan terhadap retry agresif, menjaga keamanan, dan memberikan observability yang dibutuhkan ketika konsumen webhook mencoba kembali mengirim data.