Verifying webhook signatures
Your webhook endpoint is publicly accessible, which means anyone can attempt to send requests to it. Never trust an incoming webhook payload until you have verified its signature.
Every webhook delivery sent by Qasimha is cryptographically signed using the endpoint's unique webhook secret (whsec_...) and the HMAC-SHA256 signing algorithm. Signature verification confirms that:
- The request was sent by Qasimha.
- The payload was not altered in transit.
- The webhook has not been tampered with by a third party.
Security Best Practice: Validate the signature before parsing, processing, or storing any webhook data. Reject requests with missing, invalid, or expired signatures and return an appropriate error response.
If you use one of Qasimha's server SDKs, signature verification is handled with a single method call. For custom integrations, you can implement the same validation logic yourself using HMAC-SHA256. Examples are provided for Node.js, Python, PHP, C#, and Java.
Failure to verify webhook signatures can allow attackers to spoof order events, potentially triggering unauthorized shipments, refunds, cancellations, or other business-critical actions. Always treat signature validation as the first step in your webhook processing pipeline.
How the signature works
The X-Qasimha-Signature header looks like this:
X-Qasimha-Signature: t=1758790800,v1=1eb16bb1...-
tis the Unix time (seconds) when this delivery attempt was sent. The same value is inX-Qasimha-Timestamp. -
v1isHMAC-SHA256(secret, t + "." + rawBody)in lowercase hex (64 characters). -
The key is the full secret string, including the
whsec_prefix, as UTF-8 bytes. -
rawBodyis the request body bytes exactly as sent (UTF-8 JSON). -
During a secret rotation, the header has two
v1values, one per secret:
X-Qasimha-Signature: t=1758790800,v1=<signature with the new secret>,v1=<signature with the old secret>- Each retry is signed again with a new
t. The body stays the same.
| Format | Sent to | Signed material | Replay protection |
|---|---|---|---|
t=...,v1=... | Every endpoint you register | t + "." + rawBody | Yes. t is signed, so reject old timestamps. |
sha256=<hex> | Legacy accounts with only a global URL set up by operations | rawBody | No. The timestamp header is not signed. |
The server SDKs accept both formats. New integrations only see t=...,v1=.... See the legacy note in Receiving webhooks.
Steps
- Read the request body exactly as received. Don't parse and re-serialize it first, or the bytes will differ.
- Split the header on
,and taketand everyv1value. Reject the header if it has not, more than onet, or nov1. - Reject the delivery if
tis more than 5 minutes (300 seconds) away from your server's clock. This blocks replayed requests. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare it with each
v1value using a constant-time comparison. Accept if any one matches. - Otherwise, respond
400and stop. Never fall back to processing an unsigned or failed delivery.
Code
With a server SDK
constructEvent verifies the signature and returns the parsed event. It throws on any failure; it never returns false. The secret comes from the client's webhookSecret option unless you pass one.
import express from 'express';
import Qasimha, { WebhookSignatureError } from '@qasimha/sdk';
const app = express();
const qasimha = new Qasimha({
apiKey: process.env.QASIMHA_API_KEY!,
webhookSecret: process.env.QASIMHA_WEBHOOK_SECRET,
});
// express.raw keeps req.body as the untouched Buffer. Register it before any express.json().
app.post('/webhooks/qasimha', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = qasimha.webhooks.constructEvent(req.body, req.headers);
res.sendStatus(200);
queue(event); // event: { id, type, orderId, orderReferenceId, status, amount, currency, occurredAt (the payload's createdAt), raw }
} catch (error) {
if (error instanceof WebhookSignatureError) console.warn('Rejected webhook:', error.reason);
res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
}
});constructEvent(rawBody, headers, secret?, { tolerance = 300, now? }) takes a string, Buffer or Uint8Array body and Node's req.headers, a Fetch Headers, or { signature, timestamp }. In a Next.js route handler, pass await req.text(), not req.json(). In NestJS, create the app with { rawBody: true } and pass req.rawBody.
use Qasimha\Client;
use Qasimha\Config;
use Qasimha\Exception\WebhookSignatureException;
$qasimha = new Client(Config::fromApiKey((string) getenv('QASIMHA_API_KEY'), [
'webhookSecret' => getenv('QASIMHA_WEBHOOK_SECRET') ?: null,
]));
$rawBody = file_get_contents('php://input');
$headers = function_exists('getallheaders') ? getallheaders() : $_SERVER;
try {
$event = $qasimha->webhooks()->constructEvent($rawBody, $headers);
} catch (WebhookSignatureException $e) {
http_response_code(400); // $e->reason: missing_header | malformed_header | timestamp_outside_tolerance | signature_mismatch
exit;
}
http_response_code(200);
// Queue $event->id, $event->type, $event->orderId for background processing.In Laravel pass $request->getContent() and $request->headers->all(), and exclude the route from CSRF checks. In Symfony pass $request->getContent() and $request->headers->all(). Change the tolerance with constructEvent($rawBody, $headers, null, ['tolerance' => 600]).
using Qasimha.Sdk;
using Qasimha.Sdk.Extensions.DependencyInjection;
builder.Services.AddQasimha(options =>
{
options.ApiKey = builder.Configuration["Qasimha:ApiKey"];
options.WebhookSecret = builder.Configuration["Qasimha:WebhookSecret"];
});
app.MapPost("/webhooks/qasimha", async (HttpRequest request, QasimhaClient client) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync();
WebhookEvent evt;
try
{
evt = client.Webhooks.ConstructEvent(rawBody, name => request.Headers[name]);
}
catch (WebhookSignatureException ex)
{
return Results.BadRequest(ex.ReasonCode);
}
// De-duplicate on evt.Id, enqueue evt, and answer quickly.
return Results.Ok();
});If middleware reads the body first, call request.EnableBuffering() before it and set request.Body.Position = 0 before the StreamReader. Don't bind the body to a model. Change the tolerance with new WebhookVerificationOptions { Tolerance = TimeSpan.FromMinutes(10) } as the last argument.
import com.qasimha.sdk.QasimhaClient;
import com.qasimha.sdk.exception.WebhookSignatureException;
import com.qasimha.sdk.model.WebhookEvent;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
public class QasimhaWebhookController {
private final QasimhaClient client; // built with .webhookSecret(System.getenv("QASIMHA_WEBHOOK_SECRET"))
public QasimhaWebhookController(QasimhaClient client) {
this.client = client;
}
@PostMapping("/webhooks/qasimha")
public ResponseEntity<Void> receive(
@RequestBody String rawBody, @RequestHeader Map<String, String> headers) {
try {
WebhookEvent event = client.webhooks().constructEvent(rawBody, headers);
enqueue(event); // event.getId() de-duplicates redeliveries
return ResponseEntity.ok().build();
} catch (WebhookSignatureException e) {
return ResponseEntity.badRequest().build(); // e.getReason(): MISSING_HEADER, MALFORMED_HEADER, ...
}
}
}In a servlet, read request.getInputStream().readAllBytes() and pass the byte[]. A service that only receives webhooks can use new Webhooks(secret).constructEvent(rawBody, headers) without a client. Change the tolerance with WebhookOptions.defaults().tolerance(Duration.ofMinutes(10)).
| Failure reason | Meaning |
|---|---|
missing_header | No X-Qasimha-Signature header |
malformed_header | No t, more than one t, no v1, a v1 that is not 64 hex characters, or a t that is not Unix seconds |
timestamp_outside_tolerance | t is more than the tolerance (300 seconds by default) from your clock |
signature_mismatch | No v1 matches the body and secret |
Without an SDK
These functions follow the same rules as the SDKs for the t=...,v1=... format. Each takes the raw body bytes, the X-Qasimha-Signature value and the endpoint secret, and returns true only when the delivery is genuine.
const crypto = require("node:crypto");
const express = require("express");
const TOLERANCE_SECONDS = 300;
const HEX_64 = /^[0-9a-f]{64}$/i;
function verifyQasimhaSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
let t = null;
const signatures = [];
for (const part of (header || "").split(",")) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") {
if (t !== null) return false; // more than one t
t = value;
} else if (key === "v1" && HEX_64.test(value)) {
signatures.push(Buffer.from(value, "hex"));
}
}
if (t === null || !/^\d{1,12}$/.test(t) || signatures.length === 0) return false;
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".")
.update(rawBody) // the exact bytes received
.digest();
let matched = false;
for (const sig of signatures) {
if (crypto.timingSafeEqual(sig, expected)) matched = true;
}
return matched;
}
const app = express();
// express.raw keeps the body as the exact bytes Qasimha signed
app.post("/webhooks/qasimha", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Qasimha-Signature");
if (!verifyQasimhaSignature(req.body, header, process.env.QASIMHA_WEBHOOK_SECRET)) {
return res.status(400).end();
}
res.status(200).end(); // acknowledge fast
const event = JSON.parse(req.body.toString("utf8"));
// Queue event.id for background processing. Skip ids you have already handled.
// Read GET /api/v1/merchant/orders/{event.orderId} before shipping or refunding.
});import hashlib
import hmac
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
TOLERANCE_SECONDS = 300
HEX_64 = re.compile(r"[0-9a-fA-F]{64}")
UNIX_SECONDS = re.compile(r"[0-9]{1,12}")
def verify_qasimha_signature(raw_body, header, secret, now=None):
t, signatures = None, []
for part in (header or "").split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t":
if t is not None:
return False # more than one t
t = value
elif key == "v1" and HEX_64.fullmatch(value):
signatures.append(value.lower())
if t is None or not UNIX_SECONDS.fullmatch(t) or not signatures:
return False
if abs((now if now is not None else int(time.time())) - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"), t.encode("ascii") + b"." + raw_body, hashlib.sha256
).hexdigest()
matched = False
for sig in signatures:
if hmac.compare_digest(expected, sig):
matched = True
return matched
@app.post("/webhooks/qasimha")
def qasimha_webhook():
raw = request.get_data() # exact bytes; don't re-parse first
header = request.headers.get("X-Qasimha-Signature", "")
if not verify_qasimha_signature(raw, header, os.environ["QASIMHA_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# Queue event["id"] for background processing. Skip ids you have already handled.
# Read GET /api/v1/merchant/orders/{event["orderId"]} before shipping or refunding.
return "", 200<?php
const QASIMHA_TOLERANCE_SECONDS = 300;
function verifyQasimhaSignature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
$t = null;
$signatures = [];
foreach (explode(',', (string) $header) as $pair) {
$parts = explode('=', trim($pair), 2);
if (count($parts) !== 2) {
continue;
}
$key = trim($parts[0]);
$value = trim($parts[1]);
if ($key === 't') {
if ($t !== null) {
return false; // more than one t
}
$t = $value;
} elseif ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/i', $value) === 1) {
$signatures[] = strtolower($value);
}
}
if ($t === null || preg_match('/^[0-9]{1,12}$/', $t) !== 1 || $signatures === []) {
return false;
}
if (abs(($now ?? time()) - (int) $t) > QASIMHA_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
$matched = false;
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
$matched = true;
}
}
return $matched;
}
$rawBody = file_get_contents('php://input'); // exact bytes; don't use $_POST
$header = $_SERVER['HTTP_X_QASIMHA_SIGNATURE'] ?? null;
if (!verifyQasimhaSignature($rawBody, $header, (string) getenv('QASIMHA_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
http_response_code(200);
$event = json_decode($rawBody, true);
// Queue $event['id'] for background processing. Skip ids you have already handled.
// Read GET /api/v1/merchant/orders/{$event['orderId']} before shipping or refunding.using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
app.MapPost("/webhooks/qasimha", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var rawBody = buffer.ToArray(); // exact bytes received
var header = request.Headers["X-Qasimha-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("QASIMHA_WEBHOOK_SECRET")!;
if (!QasimhaSignature.Verify(rawBody, header, secret))
return Results.BadRequest();
// Acknowledge fast; queue the event for background processing.
return Results.Ok();
});
static class QasimhaSignature
{
private const long ToleranceSeconds = 300;
public static bool Verify(byte[] rawBody, string? header, string secret, long? nowSeconds = null)
{
string? t = null;
var signatures = new List<byte[]>();
foreach (var part in (header ?? "").Split(','))
{
var kv = part.Split('=', 2);
if (kv.Length != 2) continue;
var key = kv[0].Trim();
var value = kv[1].Trim();
if (key == "t")
{
if (t is not null) return false; // more than one t
t = value;
}
else if (key == "v1" && Regex.IsMatch(value, "^[0-9a-fA-F]{64}$"))
{
signatures.Add(Convert.FromHexString(value));
}
}
if (t is null || signatures.Count == 0 || !Regex.IsMatch(t, "^[0-9]{1,12}$")) return false;
var now = nowSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - long.Parse(t)) > ToleranceSeconds) return false;
var material = Encoding.ASCII.GetBytes(t + ".").Concat(rawBody).ToArray();
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), material);
var matched = false;
foreach (var sig in signatures) matched |= CryptographicOperations.FixedTimeEquals(expected, sig);
return matched;
}
}import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class QasimhaSignature {
private static final long TOLERANCE_SECONDS = 300;
/** Java 17+. Pass System.currentTimeMillis() / 1000 as nowSeconds. */
public static boolean verify(byte[] rawBody, String header, String secret, long nowSeconds)
throws Exception {
String t = null;
List<byte[]> signatures = new ArrayList<>();
for (String part : (header == null ? "" : header).split(",")) {
int eq = part.indexOf('=');
if (eq <= 0) continue;
String key = part.substring(0, eq).trim();
String value = part.substring(eq + 1).trim();
if (key.equals("t")) {
if (t != null) return false; // more than one t
t = value;
} else if (key.equals("v1") && value.matches("[0-9a-fA-F]{64}")) {
signatures.add(HexFormat.of().parseHex(value));
}
}
if (t == null || signatures.isEmpty() || !t.matches("[0-9]{1,12}")) return false;
if (Math.abs(nowSeconds - Long.parseLong(t)) > TOLERANCE_SECONDS) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.US_ASCII));
byte[] expected = mac.doFinal(rawBody); // the exact bytes received
boolean matched = false;
for (byte[] sig : signatures) matched |= MessageDigest.isEqual(expected, sig);
return matched;
}
}
// In a servlet:
// byte[] rawBody = request.getInputStream().readAllBytes();
// boolean ok = QasimhaSignature.verify(rawBody, request.getHeader("X-Qasimha-Signature"),
// System.getenv("QASIMHA_WEBHOOK_SECRET"), System.currentTimeMillis() / 1000);
// if (!ok) { response.setStatus(400); return; }Timestamp tolerance and replay protection
- The SDKs and the code above allow 300 seconds between
tand your clock, in either direction. You can widen it in the SDKs (see each tab), but keep it short. - The signature covers
t, so an attacker cannot change the timestamp of a captured request. After 5 minutes the captured request is rejected. - A genuine delivery inside the window can still arrive twice (a retry after a timeout, or a replay). Remove duplicates with the event
id(X-Qasimha-Event-Id), which is the same on every attempt. - Retries and replays are signed again with the time of that attempt, so they pass the check even hours after the event.
- Keep your server clock in sync with NTP.
Rotating the secret
Call POST /api/v1/merchant/webhooks/{id}/rotate-secret, or rotate in the portal. The new secret is shown once.
For the next 24 hours, every delivery is signed with both the new and the old secret, so the header carries two v1 values. The response's previousSecretExpiresAt says when the old secret stops. Deploy the new secret to your servers within that window. The code above accepts either value, so nothing is dropped during the switch.
- Rotate and copy the new
secretfrom the response. - Store it in your secrets manager and deploy it to every server that verifies this endpoint.
- Check
secretHint(the last 4 characters of the current secret, fromGET /api/v1/merchant/webhooks/{id}) against the value your servers hold. - Send a test event and check it succeeds.
Rotate right away if a secret may have been exposed. Each endpoint has its own secret, so rotate each one you use.
Test vector
Use these values to check your code. All SDKs are tested against them.
secret whsec_test_0123456789abcdef
t 1758790800
body {"id":"evt_1","type":"order.confirmed","createdAt":"2026-09-25T09:00:00Z","apiVersion":"v1","orderId":"9a2e6c4b-1111-4c2d-8a1f-000000000001","orderReferenceId":"ORDER-1","status":"Confirmed","amount":1200.00,"currency":"SAR","data":{"order":{"id":"9a2e6c4b-1111-4c2d-8a1f-000000000001","orderReferenceId":"ORDER-1","status":"Confirmed","totalAmount":1200.00,"currencyCode":"SAR","capturedAmount":0,"refundedAmount":0}}}
header t=1758790800,v1=1eb16bb179f2c348a6e3d889fc70f71e9656aa50636e8d19de4de28cd03f337b
rotated t=1758790800,v1=0000000000000000000000000000000000000000000000000000000000000000,v1=1eb16bb179f2c348a6e3d889fc70f71e9656aa50636e8d19de4de28cd03f337b- With
now = 1758790920(2 minutes later), bothheaderandrotatedverify. - With
now = 1758791101(301 seconds later), both fail on the timestamp. - Add one space to the body and the signature no longer matches.
Test it
Send a test event with POST /api/v1/merchant/webhooks/{id}/ping or the portal's Send test event button. Then check the result in the delivery log (GET /api/v1/merchant/webhooks/{id}/deliveries). A Succeeded row means your server verified the signature and answered 2xx. A row with lastStatusCode: 400 means verification failed.
Security notes
- Keep the secret on your server only, in a secrets manager or environment variable. Never log it, put it in front-end code, or commit it.
- Compare signatures with a constant-time function (
timingSafeEqual,hmac.compare_digest,hash_equals,FixedTimeEquals,MessageDigest.isEqual). A plain==leaks timing. - Verify before you parse or act on the JSON.
- The signature proves the delivery came from Qasimha. It does not prove the order is still in that state. Read the order before you ship or refund.
Troubleshooting
Common verification failures
- The body was re-serialized. Your framework parsed the JSON before you hashed it. Capture the raw bytes, as
express.raworrequest.get_data()does in the examples above. - Wrong secret. Each endpoint has its own secret, and UAT and production secrets differ. Compare
secretHintfromGET /api/v1/merchant/webhookswith the secret your server holds. - The prefix was dropped. The HMAC key is the whole secret, including
whsec_. - Clock drift. Your server's clock is more than 5 minutes off. Sync it with NTP.
- Timestamp left out. You hashed only the body. The signed string is
t + "." + rawBody. - Only the first
v1was checked. During a rotation the matching signature may be the second one. Check everyv1. - Hex case or encoding. Compare the lowercase hex digest, or decode both sides to bytes first.
The SDK throws malformed_header
The header reached your code changed. A proxy or framework may have merged or trimmed it. Log the raw X-Qasimha-Signature value; it must look like t=<digits>,v1=<64 hex>.
Related
Updated about 21 hours ago
