X-Qint-Signature header carries:
X-Qint-Signature: sha256=<hex HMAC-SHA256 of the raw request body, keyed with your whsec_… secret>
sha256=, and compare against the header with a constant-time comparison. Reject the request if they differ.
Two classic mistakes break verification:
- Re-serialized JSON. Frameworks love to parse the body before you see
it.
JSON.stringify(req.body)is not the raw body — key order and whitespace change the bytes. Always verify against the exact bytes received. ==comparison. String equality short-circuits on the first mismatching byte, leaking timing information. Use your platform’s constant-time comparison.
Working examples
Each of these verifies the signature, dedupes onX-Qint-Event-Id, and acknowledges before doing real work.
const crypto = require("node:crypto");
const express = require("express");
const app = express();
const SECRET = process.env.QINT_WEBHOOK_SECRET; // whsec_…
// express.raw is essential: verification needs the exact bytes.
app.post(
"/qint/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Qint-Signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
const eventId = req.get("X-Qint-Event-Id");
// TODO: if you've already processed eventId, just return 200.
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).send("ok"); // acknowledge fast…
setImmediate(() => handleEvent(event)); // …process async
}
);
function handleEvent(event) {
if (event.type === "payment.status" && event.status === "settled") {
// fulfil the order for event.intentId
}
}
app.listen(3000);
<?php
$secret = getenv('QINT_WEBHOOK_SECRET'); // whsec_…
$payload = file_get_contents('php://input'); // raw body
$header = $_SERVER['HTTP_X_QINT_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_QINT_EVENT_ID'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $header)) { // constant-time compare
http_response_code(401);
exit('invalid signature');
}
// TODO: if $eventId was already processed, respond 200 and stop.
http_response_code(200);
echo 'ok';
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // flush the 200 before slow work
}
$event = json_decode($payload, true);
if (($event['type'] ?? '') === 'payment.status' && ($event['status'] ?? '') === 'settled') {
// fulfil the order for $event['intentId']
}
import hashlib
import hmac
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["QINT_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/qint/webhook")
def qint_webhook():
payload = request.get_data() # raw bytes — never re-serialize
header = request.headers.get("X-Qint-Signature", "")
expected = (
"sha256=" + hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
)
if not hmac.compare_digest(expected, header): # constant-time
abort(401)
event_id = request.headers.get("X-Qint-Event-Id")
# TODO: if event_id was already processed, return 200 immediately.
event = request.get_json()
# Hand off to a queue/worker; keep this handler fast.
if event.get("type") == "payment.status" and event.get("status") == "settled":
pass # enqueue fulfilment for event["intentId"]
return "ok", 200
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("QINT_WEBHOOK_SECRET")!; // whsec_…
app.MapPost("/qint/webhook", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var payload = await reader.ReadToEndAsync(); // raw body
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = "sha256=" + Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
var header = request.Headers["X-Qint-Signature"].ToString();
var a = Encoding.UTF8.GetBytes(expected);
var b = Encoding.UTF8.GetBytes(header);
if (a.Length != b.Length || !CryptographicOperations.FixedTimeEquals(a, b))
return Results.Unauthorized();
var eventId = request.Headers["X-Qint-Event-Id"].ToString();
// TODO: if eventId was already processed, return Results.Ok() now.
var evt = JsonDocument.Parse(payload).RootElement;
if (evt.GetProperty("type").GetString() == "payment.status" &&
evt.GetProperty("status").GetString() == "settled")
{
// enqueue fulfilment for evt.GetProperty("intentId").GetString()
}
return Results.Ok(); // acknowledge fast
});
app.Run();
Verify your verifier
Use the dashboard’s Send test event button (overview) — it delivers a signedping event through the production pipeline. If your handler accepts it (2xx in the delivery log) and rejects a request with a tampered body (401), you’re done.
The webhook signing secret (
whsec_…) and your API key (qk_live_…) are
different credentials with different jobs. Never use one where the other
belongs.