Webhook adalah HTTP request (biasanya POST berisi JSON) yang dikirim otomatis oleh layanan lain ke URL Anda ketika suatu peristiwa terjadi, misalnya pembayaran berhasil atau ada push ke repositori. Karena URL webhook bisa diakses siapa saja, Anda wajib memverifikasi bahwa request benar-benar berasal dari pengirim yang sah. Caranya: hitung HMAC-SHA256 dari raw body memakai secret bersama, lalu bandingkan dengan signature di header menggunakan perbandingan yang aman terhadap timing attack seperti hash_equals() di PHP atau crypto.timingSafeEqual() di Node.js.
Artikel ini membahas cara kerja webhook, langkah verifikasi signature yang benar, jebakan raw body yang sering membuat verifikasi selalu gagal, perlindungan replay dengan timestamp, idempotensi, penanganan retry, dan contoh lengkap di Laravel termasuk cara mengecualikan route dari CSRF.
Cara kerja webhook
Bayangkan dua cara mengetahui apakah pelanggan sudah membayar. Cara pertama, aplikasi Anda bertanya ke payment gateway setiap menit ("sudah bayar belum?"). Ini disebut polling, boros request dan lambat. Cara kedua, payment gateway yang memberi tahu aplikasi Anda begitu pembayaran masuk. Itulah webhook: komunikasi dibalik arahnya, dari penyedia ke aplikasi Anda.
Alurnya secara umum:
- Anda mendaftarkan URL endpoint (misalnya
https://contoh.com/webhooks/payment) di dashboard penyedia, lalu penyedia memberi Anda sebuah secret. - Saat peristiwa terjadi, penyedia menyusun payload JSON, menghitung signature HMAC dari payload itu memakai secret, lalu mengirim POST ke URL Anda dengan signature di header.
- Server Anda menghitung ulang signature dari body yang diterima memakai secret yang sama. Jika cocok, request asli; jika tidak, tolak.
- Server Anda membalas dengan status 2xx secepat mungkin. Jika penyedia tidak menerima 2xx, biasanya pengiriman akan diulang (retry).
Jika Anda belum familiar dengan request dan response HTTP, baca dulu apa itu REST API dan cara kerjanya.
Kenapa signature wajib diverifikasi
Endpoint webhook adalah URL publik. Siapa pun yang mengetahui atau menebak alamatnya bisa mengirim POST berisi JSON palsu, misalnya "pesanan #123 sudah dibayar". Tanpa verifikasi, aplikasi Anda akan menandai pesanan lunas dan mengirim barang. Memeriksa alamat IP pengirim saja kurang ideal karena daftar IP bisa berubah dan tidak semua penyedia mempublikasikannya. Signature HMAC membuktikan dua hal sekaligus: pengirim mengetahui secret, dan isi body tidak diubah di tengah jalan.
Apa itu HMAC-SHA256
HMAC (Hash-based Message Authentication Code) adalah fungsi yang menggabungkan sebuah kunci rahasia dengan pesan, lalu menghasilkan kode tetap panjangnya. Dengan SHA-256, hasilnya 32 byte, biasanya ditulis sebagai 64 karakter heksadesimal. Sifat pentingnya: tanpa mengetahui kunci, tidak ada yang bisa menghasilkan HMAC yang valid untuk pesan tertentu, dan mengubah satu karakter saja pada pesan akan menghasilkan HMAC yang sama sekali berbeda.
Di PHP:
$signature = hash_hmac('sha256', $rawBody, $secret);
// hasil: string heksadesimal 64 karakter
Anda bisa bereksperimen menghitung hash secara manual dengan Hash Generator untuk memahami bahwa input yang berbeda sedikit menghasilkan hash yang jauh berbeda.
Format signature dari penyedia populer
Setiap penyedia punya format header sendiri. Selalu baca dokumentasi resmi penyedia Anda. Dua contoh yang umum dijumpai:
- GitHub mengirim header
X-Hub-Signature-256berisisha256=diikuti HMAC-SHA256 heksadesimal dari raw body. - Stripe mengirim header
Stripe-Signatureberisi timestamp (t=) dan satu atau lebih signature (v1=). Yang di-HMAC bukan body saja, melainkan gabungantimestamp + "." + body. Stripe juga menyediakan library resmi untuk verifikasi, dan sebaiknya Anda memakainya.
Ada penyedia yang memakai encoding Base64 alih-alih heksadesimal. Jika signature Anda selalu berbeda, periksa dulu apakah encodingnya sudah sama.
Perbandingan yang aman terhadap timing attack
Membandingkan string dengan == atau === berhenti di karakter pertama yang berbeda. Secara teori, penyerang yang mengukur waktu respons dengan sangat teliti bisa menebak signature karakter demi karakter. Fungsi perbandingan "constant-time" selalu memakan waktu yang sama apa pun hasilnya.
- PHP:
hash_equals($expected, $received). Urutan argumen: string yang diketahui (hasil hitungan Anda) lebih dulu, string dari pengguna kedua. - Node.js:
crypto.timingSafeEqual(bufA, bufB). Fungsi ini melempar error jika panjang kedua buffer berbeda, jadi cek panjangnya dulu. - Python:
hmac.compare_digest(a, b).
Jebakan raw body
Ini penyebab nomor satu "signature selalu tidak cocok". Signature dihitung dari byte persis yang dikirim penyedia. Begitu body di-parse menjadi array lalu di-encode ulang menjadi JSON, hasilnya hampir pasti berbeda: urutan key, spasi, escape karakter seperti \/, atau format angka bisa berubah. Satu byte berbeda, HMAC berbeda total.
Aturannya: hitung HMAC dari body mentah sebelum disentuh apa pun.
- Laravel: gunakan
$request->getContent(), janganjson_encode($request->all()). - PHP murni:
file_get_contents('php://input'). - Express (Node.js): pasang
express.raw({ type: 'application/json' })khusus di route webhook, karenaexpress.json()sudah mengubah body menjadi objek.
Middleware yang mengubah input, misalnya pemangkas spasi otomatis (TrimStrings) atau pengubah string kosong menjadi null di Laravel, tidak memengaruhi getContent(), jadi tetap aman selama Anda memakai body mentah.
Contoh lengkap di Laravel
1. Simpan secret di .env
# .env
WEBHOOK_SECRET=isi_dengan_secret_dari_dashboard_penyedia
// config/services.php
'payment' => [
'webhook_secret' => env('WEBHOOK_SECRET'),
],
Ambil nilainya lewat config('services.payment.webhook_secret'), bukan env() langsung di controller, agar tetap berfungsi setelah php artisan config:cache.
2. Daftarkan route dan kecualikan dari CSRF
Route di routes/web.php dilindungi middleware CSRF, sehingga POST dari penyedia akan ditolak dengan status 419. Ada dua pilihan. Pertama, letakkan route di routes/api.php, yang tidak memakai CSRF. Kedua, tetap di web.php tetapi kecualikan path-nya.
Laravel 11 ke atas, di bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
})
Laravel 10, di app/Http/Middleware/VerifyCsrfToken.php:
Berlangganan gratis. Artikel & tutorial coding terbaru langsung ke email kamu. Tanpa spam.
protected $except = [
'webhooks/*',
];
// routes/web.php
use App\Http\Controllers\PaymentWebhookController;
Route::post('/webhooks/payment', PaymentWebhookController::class);
3. Controller dengan verifikasi, anti-replay, dan idempotensi
Contoh di bawah mengasumsikan penyedia mengirim header X-Signature (HMAC heksadesimal dari timestamp.body) dan X-Timestamp (Unix detik). Sesuaikan nama header dan format dengan dokumentasi penyedia Anda.
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessPaymentEvent;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;
class PaymentWebhookController extends Controller
{
public function __invoke(Request $request)
{
$secret = config('services.payment.webhook_secret');
$payload = $request->getContent(); // raw body
$timestamp = (int) $request->header('X-Timestamp');
$received = (string) $request->header('X-Signature');
// 1) tolak request yang terlalu lama (anti-replay), toleransi 5 menit
if (abs(time() - $timestamp) > 300) {
abort(400, 'Timestamp di luar toleransi');
}
// 2) hitung dan bandingkan signature secara constant-time
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (! hash_equals($expected, $received)) {
abort(401, 'Signature tidak valid');
}
$event = json_decode($payload, true);
// 3) idempotensi: abaikan event yang sudah pernah diterima
$record = WebhookEvent::firstOrCreate(
['event_id' => $event['id']],
['type' => $event['type'], 'payload' => $payload]
);
if (! $record->wasRecentlyCreated) {
return response()->noContent(); // duplikat, tetap balas 2xx
}
// 4) proses berat di queue, balas secepatnya
ProcessPaymentEvent::dispatch($record->id);
return response()->noContent(); // 204
}
}
Tabel webhook_events perlu unique index pada kolom event_id, supaya dua request duplikat yang datang bersamaan tidak sama-sama lolos:
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique();
$table->string('type');
$table->longText('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
Contoh verifikasi di Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.post('/webhooks/github',
express.raw({ type: 'application/json' }), // body tetap Buffer
(req, res) => {
const header = req.get('X-Hub-Signature-256') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// antrekan pemrosesan di sini
res.sendStatus(204);
});
Perlindungan replay dengan timestamp
Signature yang valid membuktikan request berasal dari penyedia, tetapi tidak membuktikan request itu baru. Penyerang yang berhasil menyadap satu request valid bisa mengirim ulang persis sama berkali-kali (replay attack). Signature tetap cocok karena body dan secret tidak berubah.
Solusinya: penyedia menyertakan timestamp dan ikut memasukkannya ke data yang di-HMAC. Server Anda menolak request yang timestamp-nya terlalu jauh dari waktu sekarang. Karena timestamp ikut ditandatangani, penyerang tidak bisa menggantinya tanpa merusak signature. Stripe, misalnya, memakai toleransi bawaan lima menit di library resminya. Pastikan jam server Anda sinkron lewat NTP, karena selisih jam yang besar akan membuat request sah ikut ditolak.
Untuk perlindungan ekstra, kombinasikan dengan pencatatan ID event (idempotensi di bawah), sehingga request yang diputar ulang di dalam jendela waktu tetap tidak diproses dua kali.
Idempotensi: siap menerima event yang sama berkali-kali
Hampir semua penyedia menjamin pengiriman "setidaknya sekali" (at-least-once), bukan "tepat sekali". Artinya event yang sama bisa datang dua kali atau lebih, misalnya karena server Anda sudah memproses tetapi respons-nya timeout di jalan. Endpoint Anda harus idempoten: memproses event yang sama berulang kali tidak boleh menghasilkan efek ganda seperti saldo bertambah dua kali atau email terkirim dua kali.
Pola paling sederhana adalah menyimpan ID event yang unik dengan unique constraint di database, seperti contoh di atas. Selain itu, buat logika bisnis yang aman diulang, misalnya "set status menjadi lunas" alih-alih "tambah saldo sebesar X".
Retry dan respons cepat
Penyedia menganggap pengiriman gagal jika menerima status selain 2xx atau tidak mendapat jawaban dalam batas waktu tertentu. Perilaku retry berbeda-beda: Stripe, misalnya, mencoba ulang dengan exponential backoff hingga beberapa hari di mode live, sementara GitHub tidak mengirim ulang otomatis dan menyediakan tombol redeliver manual. Beberapa hal yang perlu diperhatikan:
- Balas cepat, proses belakangan. Verifikasi, simpan, antrekan job, lalu balas 2xx. Jangan mengirim email, memanggil API lain, atau membuat PDF di dalam request webhook.
- Gunakan status yang tepat. Signature salah: 400/401 (penyedia mungkin akan retry, tetapi tetap ditolak). Error sementara di sisi Anda: 5xx agar penyedia mencoba lagi. Event yang tidak Anda pedulikan: tetap 2xx.
- Jangan andalkan urutan. Event bisa datang tidak berurutan. Jika urutan penting, bandingkan timestamp event atau ambil status terbaru langsung dari API penyedia.
- Catat semuanya. Simpan payload mentah untuk debugging dan pemrosesan ulang.
Kesalahan umum
- Menghitung HMAC dari JSON yang sudah di-parse lalu di-encode ulang, bukan raw body.
- Membandingkan signature dengan
==atau===. - Lupa prefix seperti
sha256=atau salah encoding (hex vs Base64). - Route webhook terkena CSRF sehingga selalu 419.
- Memakai
env('WEBHOOK_SECRET')langsung di controller, lalu bernilai null setelahconfig:cache. - Menyimpan secret di repositori atau menampilkannya di log.
- Memproses pekerjaan berat secara sinkron sehingga timeout dan memicu retry yang membuat duplikat.
- Ada middleware autentikasi atau redirect HTTP ke HTTPS di depan endpoint. Banyak pengirim webhook tidak mengikuti redirect, dan yang mengikutinya bisa mengubah POST menjadi GET sehingga body hilang.
Checklist endpoint webhook
- Endpoint memakai HTTPS.
- Secret disimpan di .env, dibaca lewat config.
- HMAC dihitung dari raw body sesuai format penyedia.
- Perbandingan memakai
hash_equals/timingSafeEqual. - Timestamp diperiksa terhadap toleransi waktu.
- ID event disimpan dengan unique index.
- Pekerjaan berat dijalankan di queue; endpoint membalas 2xx dengan cepat.
- Route dikecualikan dari CSRF tanpa mematikan CSRF untuk route lain.
FAQ
Apakah HTTPS saja sudah cukup tanpa signature?
Tidak. HTTPS melindungi data dari penyadapan di jalan, tetapi tidak membuktikan siapa pengirimnya. Siapa pun tetap bisa mengirim POST via HTTPS ke URL Anda. Signature membuktikan pengirim memegang secret.
Bagaimana menguji webhook di komputer lokal?
Gunakan tunnel seperti ngrok atau Cloudflare Tunnel untuk membuat URL publik yang meneruskan ke localhost, atau gunakan CLI resmi penyedia jika ada (Stripe CLI misalnya bisa meneruskan event ke server lokal). Anda juga bisa menyusun request sendiri dengan curl dan signature yang dihitung manual.
Apa yang harus dilakukan jika secret bocor?
Buat secret baru di dashboard penyedia, perbarui .env, lalu jalankan ulang php artisan config:cache. Beberapa penyedia mengizinkan dua secret aktif sementara agar rotasi tidak memutus pengiriman.
Apakah webhook bisa memakai metode selain POST?
Secara teknis bisa, tetapi hampir semua penyedia memakai POST dengan body JSON. Beberapa juga mengirim GET satu kali untuk verifikasi kepemilikan endpoint (challenge), misalnya dengan mengembalikan nilai tertentu.
Perlukah membatasi IP pengirim?
Boleh sebagai lapisan tambahan jika penyedia mempublikasikan daftar IP-nya, tetapi jangan menjadikannya pengganti verifikasi signature. Pembatasan IP juga perlu dirawat setiap kali daftar IP penyedia berubah.