Konek QR

Memverifikasi webhook Konek QR

Cara memverifikasi tanda tangan Standard Webhooks di PHP, Node, Python, dan Go.

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

HTTP
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…}}
HeaderIsi
webhook-idid event (evt_…). Sama di setiap percobaan ulang, jadi pakai untuk menyaring kiriman ganda.
webhook-timestampdetik Unix saat percobaan ini ditandatangani.
webhook-signaturev1, + 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

  1. Ambil body mentah persis seperti diterima. Jangan parse lalu encode ulang JSON-nya: urutan atau spasi yang berubah membuat tanda tangan tidak cocok.
  2. Tolak kalau webhook-timestamp lebih dari 5 menit dari jam servermu (mencegah kiriman lama diputar ulang).
  3. Hitung tanda tangan, bandingkan dengan perbandingan constant-time, terima kalau salah satu tanda tangan v1 cocok.
  4. Balas 2xx dalam 15 detik, lalu proses pesanan di antrean. Redirect (3xx) dihitung gagal.
  5. Simpan webhook-id yang 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):

secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
body{"test": 2432232314}
webhook-signature yang benarv1,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:

Shell
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
<?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:

PHP
$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().

JavaScript
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:

JavaScript
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+)

Python
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

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

GejalaPenyebabPerbaikan
Tanda tangan selalu salahbody sudah di-parse framework, atau secret dipakai sebagai tekspakai body mentah; decode base64 bagian setelah whsec_
Laravel menjawab 419proteksi CSRFkecualikan route webhook dari VerifyCsrfToken ($except, atau validateCsrfTokens(except: [...]) di bootstrap/app.php)
Cloudflare menjawab 403/503tantangan botWAF custom rule dengan aksi Skip untuk User-Agent KonekQR-Webhooks dan path webhook-mu
Hosting menjawab 406/403 HTMLModSecuritykecualikan path webhook di ModSecurity (cPanel)
Timeoutproses berat sebelum menjawabjawab 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].