Konek QR

Verifying Konek QR webhooks

How to verify Standard Webhooks signatures in PHP, Node, Python and Go.

Every webhook from Konek QR is signed in the Standard Webhooks format. Verify the signature before you process an order: anyone can send a POST to your webhook URL, but only Konek QR knows the secret.

What Konek QR sends

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":{…invoice…}}
HeaderContents
webhook-idthe event id (evt_…). It stays the same on every retry, so use it to filter out duplicate deliveries.
webhook-timestampUnix seconds when this attempt was signed.
webhook-signaturev1, + base64(HMAC-SHA256(key, webhook-id + . + webhook-timestamp + . + body)). For 24 hours after the secret is changed there are two signatures, separated by a space; accept the delivery if either one matches.

The key is the part of the secret after whsec_, decoded from base64 (not the secret text as it is). You can see the secret in Dashboard → Webhooks → "Show secret".

Verification rules

  1. Take the raw body exactly as received. Do not parse the JSON and encode it again: a changed order or spacing makes the signature fail.
  2. Reject the delivery if webhook-timestamp is more than 5 minutes away from your server clock (this stops old deliveries from being replayed).
  3. Compute the signature, compare it in constant time, and accept the delivery if any v1 signature matches.
  4. Answer with a 2xx within 15 seconds, then process the order in a queue. A redirect (3xx) counts as a failure.
  5. Store the webhook-id values you have processed and ignore repeats: delivery is at least once.

If verification fails, answer 400 (not 401 or 403: Webhook Doctor reads both as "this route needs a login", so the delivery log would tell you to check your auth middleware instead of the secret). Konek QR retries on a schedule (1 minute, 5 minutes, 30 minutes, then 2, 5, 10, 24, 48 and 72 hours), and Webhook Doctor in the delivery log shows the cause.

Test vector

Every example below has been run against this official Standard Webhooks vector. Use it to test your own code too (with the time tolerance turned off, since the timestamp is from 2021):

secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
body{"test": 2432232314}
correct webhook-signaturev1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

cURL: send a signed webhook to your own server

The quickest way to test an endpoint is the "Send test event" button in Dashboard → Webhooks. To test from a terminal (a local server behind a tunnel, for example), make the signature yourself with openssl and send it with curl:

Shell
SECRET='whsec_your_webhook_secret'
URL='https://yourshop.com/webhooks/konekqr'
ID="evt_test_$(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"

Your server should answer with a 2xx. Now change one letter of BODY after the signature is made: this time your server should reject it.

PHP

No extra library needed. For Laravel, use the VerifyKonekQrWebhook middleware from the konekqr/laravel package (composer require konekqr/laravel).

PHP
<?php
/**
 * Verifies a Konek QR webhook. Returns the event (array) or throws a 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 is too far from the server clock');
    }
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    if ($key === false) {
        throw new RuntimeException('invalid secret');
    }
    $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 does not match');
}

In plain PHP:

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') {
    // mark order $event['data']['referenceId'] as paid (skip it if this webhook-id was already processed)
}
http_response_code(204);

Node.js

Use the built-in node:crypto. The @konekqr/node package (npm install @konekqr/node) also has verifyWebhook().

JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';

// Returns the event, or throws an Error. `headers` holds the headers with lowercase names.
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 is too far from the server clock');
  }
  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 does not match');
}

With Express, get the raw body with express.raw() on this route only:

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') {
    // queue it: mark order event.data.referenceId as paid
  }
  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:
    """Returns the event, or raises ValueError. Header names in lowercase."""
    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 is too far from the server clock")
    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 does not match")

With Flask: verify_konekqr_webhook(secret, {k.lower(): v for k, v in request.headers.items()}, request.get_data()). With Django: request.body and request.headers.

Go

Go
package konekqr

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// VerifyWebhook checks the signature of a Konek QR webhook and returns its event.
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 is too far from the server clock")
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return nil, errors.New("invalid secret")
	}
	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 does not match")
}

In your handler: read the body with io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)), call VerifyWebhook(secret, r.Header, body, time.Now()), answer 400 on an error and 204 on success.

Common problems

SymptomCauseFix
The signature is always wrongthe framework already parsed the body, or the secret is used as plain textuse the raw body; base64-decode the part after whsec_
Laravel answers 419CSRF protectionexclude the webhook route from VerifyCsrfToken ($except, or validateCsrfTokens(except: [...]) in bootstrap/app.php)
Cloudflare answers 403/503bot challengea WAF custom rule with the Skip action for the User-Agent KonekQR-Webhooks and your webhook path
Your host answers 406/403 with HTMLModSecurityexclude the webhook path from ModSecurity (cPanel)
Timeoutheavy work before answeringanswer 2xx first, then do the work in a queue

Webhook Doctor in the dashboard's delivery log also recognizes all of these causes automatically.

Something unclear or wrong? Write to [email protected].