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
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…}}| Header | Contents |
|---|---|
webhook-id | the event id (evt_…). It stays the same on every retry, so use it to filter out duplicate deliveries. |
webhook-timestamp | Unix seconds when this attempt was signed. |
webhook-signature | v1, + 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
- 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.
- Reject the delivery if
webhook-timestampis more than 5 minutes away from your server clock (this stops old deliveries from being replayed). - Compute the signature, compare it in constant time, and accept the delivery if any
v1signature matches. - Answer with a 2xx within 15 seconds, then process the order in a queue. A redirect (3xx) counts as a failure.
- Store the
webhook-idvalues 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):
| secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
|---|---|
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp | 1614265330 |
| body | {"test": 2432232314} |
correct webhook-signature | v1,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:
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
/**
* 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:
$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().
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:
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+)
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
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
| Symptom | Cause | Fix |
|---|---|---|
| The signature is always wrong | the framework already parsed the body, or the secret is used as plain text | use the raw body; base64-decode the part after whsec_ |
| Laravel answers 419 | CSRF protection | exclude the webhook route from VerifyCsrfToken ($except, or validateCsrfTokens(except: [...]) in bootstrap/app.php) |
| Cloudflare answers 403/503 | bot challenge | a WAF custom rule with the Skip action for the User-Agent KonekQR-Webhooks and your webhook path |
| Your host answers 406/403 with HTML | ModSecurity | exclude the webhook path from ModSecurity (cPanel) |
| Timeout | heavy work before answering | answer 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].