Setiap webhook dari Konek QR ditandatangani dengan format Standard Webhooks. Verifikasi tanda tangannya sebelum memproses pesanan: siapa pun bisa mengirim POST ke URL webhook-mu, tetapi hanya Konek QR yang tahu secret-nya.
Yang dikirim Konek QR
POST /webhooks/konekqr HTTP/1.1
content-type: application/json
user-agent: KonekQR-Webhooks/1
webhook-id: evt_01972d1f-0a11-7b22-8c33-4d5e6f708192
webhook-timestamp: 1793784672
webhook-signature: v1,…base64…
{"id":"evt_01972d1f-0a11-7b22-8c33-4d5e6f708192","type":"payment.paid","livemode":true,"createdAt":"2026-11-04T09:31:12Z","data":{…tagihan…}}| Header | Isi |
|---|---|
webhook-id | id event (evt_…). Sama di setiap percobaan ulang, jadi pakai untuk menyaring kiriman ganda. |
webhook-timestamp | detik Unix saat percobaan ini ditandatangani. |
webhook-signature | v1, + base64(HMAC-SHA256(kunci, webhook-id + . + webhook-timestamp + . + body)). Setelah secret dirotasi, selama 24 jam ada dua tanda tangan dipisah spasi; terima kalau salah satunya cocok. |
kunci adalah bagian setelah whsec_ pada secret, di-decode dari base64 (bukan teks secret apa adanya). Secret dilihat di Dashboard → Webhook → "Lihat secret".
Aturan verifikasi
- Ambil body mentah persis seperti diterima. Jangan parse lalu encode ulang JSON-nya: urutan atau spasi yang berubah membuat tanda tangan tidak cocok.
- Tolak kalau
webhook-timestamplebih dari 5 menit dari jam servermu (mencegah kiriman lama diputar ulang). - Hitung tanda tangan, bandingkan dengan perbandingan constant-time, terima kalau salah satu tanda tangan
v1cocok. - Balas 2xx dalam 15 detik, lalu proses pesanan di antrean. Redirect (3xx) dihitung gagal.
- Simpan
webhook-idyang sudah diproses dan abaikan yang sama: pengiriman bersifat at least once.
Kalau verifikasi gagal, balas 400 (bukan 401 atau 403: Dokter Webhook membaca keduanya sebagai "route ini butuh login", sehingga log pengiriman menyuruhmu memeriksa middleware auth, bukan secret). Konek QR akan mencoba lagi sesuai jadwal (1 menit, 5 menit, 30 menit, 2, 5, 10, 24, 48, dan 72 jam), dan Dokter Webhook di log pengiriman menunjukkan penyebabnya.
Vektor uji
Semua contoh di bawah sudah dijalankan dengan vektor resmi Standard Webhooks ini. Pakai juga untuk menguji kodemu sendiri (dengan toleransi waktu dimatikan, karena timestamp-nya dari 2021):
| secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
|---|---|
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp | 1614265330 |
| body | {"test": 2432232314} |
webhook-signature yang benar | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
cURL: kirim webhook bertanda tangan ke servermu sendiri
Cara tercepat menguji endpoint adalah tombol "Kirim event uji" di Dashboard → Webhook. Untuk menguji dari terminal (misalnya server lokal lewat tunnel), buat tanda tangan sendiri dengan openssl lalu kirim dengan curl:
SECRET='whsec_isi_secret_webhookmu'
URL='https://tokomu.com/webhooks/konekqr'
ID="evt_uji_$(date +%s)"
TS=$(date +%s)
BODY='{"id":"'"$ID"'","type":"webhook.test","livemode":false,"createdAt":"2026-11-04T09:00:00Z","data":{}}'
KEY_HEX=$(printf %s "${SECRET#whsec_}" | base64 -d | od -An -v -tx1 | tr -d ' \n')
SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | base64)
curl -i "$URL" \
-H 'content-type: application/json' \
-H "webhook-id: $ID" \
-H "webhook-timestamp: $TS" \
-H "webhook-signature: v1,$SIG" \
--data-raw "$BODY"Servermu harus menjawab 2xx. Ubah satu huruf BODY setelah tanda tangan dibuat: sekarang servermu harus menolak.
PHP
Tanpa library tambahan. Untuk Laravel, pakai middleware VerifyKonekQrWebhook dari paket konekqr/laravel (composer require konekqr/laravel).
<?php
/**
* Verifikasi webhook Konek QR. Mengembalikan event (array) atau melempar RuntimeException.
*/
function konekqr_verify_webhook(string $secret, string $id, string $timestamp, string $signatures, string $body, ?int $now = null): array
{
$now ??= time();
if (!ctype_digit($timestamp) || abs($now - (int) $timestamp) > 300) {
throw new RuntimeException('webhook-timestamp terlalu jauh dari jam server');
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
if ($key === false) {
throw new RuntimeException('secret tidak valid');
}
$expected = base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $body, $key, true));
foreach (explode(' ', $signatures) as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}
}
throw new RuntimeException('webhook-signature tidak cocok');
}Dipakai di PHP biasa:
$body = file_get_contents('php://input');
try {
$event = konekqr_verify_webhook(
getenv('KONEKQR_WEBHOOK_SECRET'),
$_SERVER['HTTP_WEBHOOK_ID'] ?? '',
$_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
$_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
$body,
);
} catch (Throwable $e) {
http_response_code(400);
exit;
}
if ($event['type'] === 'payment.paid') {
// tandai pesanan $event['data']['referenceId'] lunas (abaikan kalau webhook-id ini sudah pernah diproses)
}
http_response_code(204);Node.js
Pakai node:crypto bawaan. Paket @konekqr/node (npm install @konekqr/node) juga punya verifyWebhook().
import { createHmac, timingSafeEqual } from 'node:crypto';
// Mengembalikan event, atau melempar Error. `headers` berisi header dengan nama huruf kecil.
export function verifyKonekQRWebhook(secret, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
const id = headers['webhook-id'] ?? '';
const timestamp = headers['webhook-timestamp'] ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) {
throw new Error('webhook-timestamp terlalu jauh dari jam server');
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
for (const signature of (headers['webhook-signature'] ?? '').split(' ')) {
const [version, value = ''] = signature.split(',', 2);
const given = Buffer.from(value, 'base64');
if (version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected)) {
return JSON.parse(rawBody.toString());
}
}
throw new Error('webhook-signature tidak cocok');
}Dengan Express, ambil body mentah dengan express.raw() khusus di route ini:
app.post('/webhooks/konekqr', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verifyKonekQRWebhook(process.env.KONEKQR_WEBHOOK_SECRET, req.headers, req.body);
} catch {
return res.sendStatus(400);
}
if (event.type === 'payment.paid') {
// antrekan: tandai pesanan event.data.referenceId lunas
}
res.sendStatus(204);
});Python (3.10+)
import base64
import hashlib
import hmac
import json
import time
def verify_konekqr_webhook(secret: str, headers: dict, body: bytes, now: float | None = None) -> dict:
"""Mengembalikan event, atau melempar ValueError. Nama header huruf kecil."""
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
now = time.time() if now is None else now
if not timestamp.isdigit() or abs(now - int(timestamp)) > 300:
raise ValueError("webhook-timestamp terlalu jauh dari jam server")
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(
hmac.new(key, f"{msg_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
).decode()
for signature in headers.get("webhook-signature", "").split(" "):
version, _, value = signature.partition(",")
if version == "v1" and hmac.compare_digest(expected, value):
return json.loads(body)
raise ValueError("webhook-signature tidak cocok")Dengan Flask: verify_konekqr_webhook(secret, {k.lower(): v for k, v in request.headers.items()}, request.get_data()). Dengan Django: request.body dan request.headers.
Go
package konekqr
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
// VerifyWebhook memeriksa tanda tangan webhook Konek QR dan mengembalikan event-nya.
func VerifyWebhook(secret string, h http.Header, body []byte, now time.Time) (map[string]any, error) {
id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || now.Sub(time.Unix(sec, 0)).Abs() > 5*time.Minute {
return nil, errors.New("webhook-timestamp terlalu jauh dari jam server")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return nil, errors.New("secret tidak valid")
}
m := hmac.New(sha256.New, key)
m.Write([]byte(id + "." + ts + "."))
m.Write(body)
expected := m.Sum(nil)
for _, sig := range strings.Split(h.Get("webhook-signature"), " ") {
version, value, _ := strings.Cut(sig, ",")
given, err := base64.StdEncoding.DecodeString(value)
if version == "v1" && err == nil && hmac.Equal(given, expected) {
var event map[string]any
return event, json.Unmarshal(body, &event)
}
}
return nil, errors.New("webhook-signature tidak cocok")
}Di handler: baca body dengan io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)), panggil VerifyWebhook(secret, r.Header, body, time.Now()), balas 400 kalau error, 204 kalau berhasil.
Masalah yang sering terjadi
| Gejala | Penyebab | Perbaikan |
|---|---|---|
| Tanda tangan selalu salah | body sudah di-parse framework, atau secret dipakai sebagai teks | pakai body mentah; decode base64 bagian setelah whsec_ |
| Laravel menjawab 419 | proteksi CSRF | kecualikan route webhook dari VerifyCsrfToken ($except, atau validateCsrfTokens(except: [...]) di bootstrap/app.php) |
| Cloudflare menjawab 403/503 | tantangan bot | WAF custom rule dengan aksi Skip untuk User-Agent KonekQR-Webhooks dan path webhook-mu |
| Hosting menjawab 406/403 HTML | ModSecurity | kecualikan path webhook di ModSecurity (cPanel) |
| Timeout | proses berat sebelum menjawab | jawab 2xx dulu, proses di antrean |
Semua penyebab ini juga dikenali otomatis oleh Dokter Webhook di log pengiriman dashboard.
Ada yang kurang jelas atau salah? Kirim ke [email protected].