Setup & Konfigurasi Webhook
1. Daftarkan URL Webhook
- Login ke Dashboard IsiKuota
- Buka Pengaturan → API → Webhook
- Masukkan URL endpoint server Anda (harus HTTPS di production)
- Klik Simpan
Contoh URL webhook:
https://yourdomain.com/webhook/isikuota
warning
URL webhook harus dapat diakses publik dari internet. Tidak bisa menggunakan localhost atau IP private di production.
2. Implementasi Handler Webhook
Satu URL, dua jenis event
Contoh di bawah menangani payload berdasarkan data.status untuk event transaction.updated. URL webhook yang sama juga menerima event deposit.updated (field event berbeda, struktur data juga berbeda — termasuk status tambahan expired/cancelled yang tidak dipakai transaksi). Cek payload.event lebih dulu sebelum switch berdasarkan status. Detail lengkap & contoh payload tiap event ada di Events.
- PHP (Native)
- PHP (Laravel)
- JavaScript (Express.js)
- Python (Flask)
- Java
- C#
<?php
// webhook.php
$secret = $_ENV['ISIKUOTA_SECRET_KEY'];
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_ISIKUOTA_SIGNATURE'] ?? '';
// Verifikasi signature (nilai header berformat "sha256=<hex>")
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $sigHeader)) {
http_response_code(401);
exit(json_encode(['error' => 'Invalid signature']));
}
$payload = json_decode($rawBody, true);
$data = $payload['data'];
$refId = $data['ref_id'];
// Cek idempoten — jangan proses ulang yang sudah diproses
if (alreadyProcessed($payload['event_id'])) {
http_response_code(200);
exit(json_encode(['status' => 'already_processed']));
}
// Proses berdasarkan status
switch ($data['status']) {
case 'success':
updateOrderStatus($refId, 'success', $data['sn']);
sendNotificationToUser($refId);
break;
case 'failed':
updateOrderStatus($refId, 'failed');
sendFailureNotification($refId, $data['message']);
break;
case 'pending':
updateOrderStatus($refId, 'pending');
break;
}
// Selalu balas 200 dengan cepat
http_response_code(200);
echo json_encode(['status' => 'ok']);
<?php
// routes/api.php
Route::post('/webhook/isikuota', [WebhookController::class, 'handle']);
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function handle(Request $request)
{
$secret = config('services.isikuota.secret');
$rawBody = $request->getContent();
$signature = $request->header('X-Isikuota-Signature');
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, (string) $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
$payload = $request->json()->all();
// Dispatch ke job untuk proses di background
ProcessWebhook::dispatch($payload);
return response()->json(['status' => 'ok']);
}
}
// app/Jobs/ProcessWebhook.php
class ProcessWebhook implements ShouldQueue
{
public function handle(): void
{
$data = $this->payload['data'];
match ($data['status']) {
'success' => $this->handleSuccess($data),
'failed' => $this->handleFailed($data),
default => null,
};
}
}
const express = require('express');
const crypto = require('crypto');
const app = express();
// Gunakan raw body parser agar bisa verifikasi signature
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook/isikuota', (req, res) => {
const secret = process.env.ISIKUOTA_SECRET_KEY;
const rawBody = req.body;
const signature = req.headers['x-isikuota-signature'];
// Verifikasi signature (nilai header berformat "sha256=<hex>")
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Balas 200 dulu, proses di background
res.status(200).json({ status: 'ok' });
const payload = JSON.parse(rawBody);
// Proses asinkron
processWebhook(payload).catch(console.error);
});
async function processWebhook(payload) {
const { data } = payload;
switch (data.status) {
case 'success':
await updateOrder(data.ref_id, 'success', data.sn);
break;
case 'failed':
await updateOrder(data.ref_id, 'failed');
break;
}
}
import hmac, hashlib, json
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = 'your_secret_key'
@app.route('/webhook/isikuota', methods=['POST'])
def webhook():
raw_body = request.get_data()
signature = request.headers.get('X-Isikuota-Signature', '')
# Verifikasi signature (nilai header berformat "sha256=<hex>")
expected = 'sha256=' + hmac.new(
SECRET.encode(),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return jsonify({'error': 'Invalid signature'}), 401
payload = json.loads(raw_body)
data = payload['data']
# Balas 200 dulu
response = jsonify({'status': 'ok'})
# Proses di background (gunakan Celery/threading)
process_status.delay(data)
return response, 200
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.*;
import java.io.*;
import java.util.*;
public class WebhookHandler implements HttpHandler {
static final String SECRET = System.getenv("ISIKUOTA_SECRET_KEY");
static final ObjectMapper MAPPER = new ObjectMapper();
static boolean verifySignature(String rawBody, String signature) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) sb.append(String.format("%02x", b));
String expected = "sha256=" + sb;
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8)
);
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String rawBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
String signature = exchange.getRequestHeaders().getFirst("X-Isikuota-Signature");
try {
if (signature == null || !verifySignature(rawBody, signature)) {
sendJson(exchange, 401, "{\"error\":\"Invalid signature\"}");
return;
}
Map<String, Object> payload = MAPPER.readValue(rawBody, Map.class);
Map<String, Object> data = (Map<String, Object>) payload.get("data");
String refId = (String) data.get("ref_id");
String status = (String) data.get("status");
// Cek idempoten — jangan proses ulang yang sudah diproses
if (alreadyProcessed((String) payload.get("event_id"))) {
sendJson(exchange, 200, "{\"status\":\"already_processed\"}");
return;
}
switch (status) {
case "success" -> updateOrderStatus(refId, "success", (String) data.get("sn"));
case "failed" -> updateOrderStatus(refId, "failed");
case "pending" -> updateOrderStatus(refId, "pending");
}
sendJson(exchange, 200, "{\"status\":\"ok\"}");
} catch (Exception e) {
sendJson(exchange, 500, "{\"error\":\"Internal error\"}");
}
}
static void sendJson(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.getResponseBody().close();
}
}
// ASP.NET Core minimal API
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("ISIKUOTA_SECRET_KEY")!;
app.MapPost("/webhook/isikuota", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync();
var signature = request.Headers["X-Isikuota-Signature"].ToString();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
var expected = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLower();
if (string.IsNullOrEmpty(signature) || !CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)))
{
return Results.Json(new { error = "Invalid signature" }, statusCode: 401);
}
var payload = JsonSerializer.Deserialize<JsonElement>(rawBody);
var data = payload.GetProperty("data");
var refId = data.GetProperty("ref_id").GetString();
var status = data.GetProperty("status").GetString();
// Balas 200 dulu, proses di background
_ = Task.Run(() =>
{
switch (status)
{
case "success": UpdateOrderStatus(refId!, "success"); break;
case "failed": UpdateOrderStatus(refId!, "failed"); break;
case "pending": UpdateOrderStatus(refId!, "pending"); break;
}
});
return Results.Json(new { status = "ok" });
});
app.Run();
3. Test Webhook
Gunakan fitur Webhook Tester di Dashboard untuk mengirim dummy payload ke URL webhook Anda:
- Login ke Dashboard, buka halaman API Documentation
- Pilih tab Webhook Tester
- Isi data dummy (ref_id, status, produk, dll)
- Klik Test Webhook dan periksa log server Anda
Payload & signature yang dikirim Webhook Tester identik dengan production, sehingga aman untuk menguji handler Anda.
Checklist Setup Webhook
- URL webhook terdaftar di Dashboard
- Server dapat diakses via HTTPS
- Verifikasi
X-Isikuota-Signaturediimplementasikan (dengan prefixsha256=) - Menggunakan timing-safe comparison (
hash_equals/timingSafeEqual) - Server membalas HTTP 2xx dalam 15 detik
- Proses berat dikerjakan di background/queue
- Implementasi idempoten (cek
event_idsebelum proses) - Cek field
eventuntuk membedakantransaction.updatedvsdeposit.updatedsebelum memprosesdata - Log setiap callback tersimpan