Webhook
When an asynchronous analysis is completed, a webhook is sent with the analysis result. To do so, it is necessary to configure an address where we will send the notifications and also a signature_key that will be used to sign the request. If you do not yet have a webhook configured, contact the support team.
Webhook Signature
To guarantee that the request received at your endpoint came from our servers, we send an HMAC signature in the Signature header. You recompute that signature on your side and compare it with the one received — if they match, the request is trustworthy.
How the signature is computed
Signature = HMAC-SHA1(signature_key, endpoint + method + payload) → hexadecimal
The three components are concatenated in this order, with no separator:
| Component | What it is |
|---|---|
endpoint | The full URL of your webhook, exactly as configured with support (including https:// and any query string). |
method | The HTTP verb in uppercase — POST for these notifications. |
payload | The request body exactly as received, byte for byte. |
signature_key | The secret you agreed on with support (also called secret_token). It is the HMAC key, not part of the message. |
The signature is computed over the exact bytes of the body. If you deserialize the JSON and serialize it again before validating, key order and whitespace change, and the signature will never match.
Read the body as a raw string/bytes first, validate the signature, and only then parse it. In the examples below this appears as request.get_data(), file_get_contents('php://input'), express.raw() and so on.
The body is serialized with ensure_ascii=False, meaning accented characters are sent as literal UTF-8 ("João") rather than escaped. Treat the body as UTF-8 when computing the HMAC — this is the default in every language below, but it is the most common cause of a mismatched signature when a field contains an accent.
Validation examples
- Python
- PHP
- Node.js
- Java
- C#
import hashlib
import hmac
SIGNATURE_KEY = "YOUR_SECRET_TOKEN"
WEBHOOK_URL = "https://seu-dominio.com/webhooks/qitech"
HTTP_METHOD = "HTTP_VERB" # see the table above
def calculate_signature(endpoint: str, method: str, payload: str) -> str:
hmac_obj = hmac.new(
SIGNATURE_KEY.encode("utf-8"),
(endpoint + method + payload).encode("utf-8"),
hashlib.sha1,
)
return hmac_obj.hexdigest()
def is_valid(received_signature: str, raw_body: str) -> bool:
expected = calculate_signature(WEBHOOK_URL, HTTP_METHOD, raw_body)
# compare_digest prevents timing attacks
return hmac.compare_digest(expected, received_signature)
# Exemplo com Flask
from flask import Flask, request
app = Flask(__name__)
@app.route("/webhooks/qitech", methods=[HTTP_METHOD])
def receive_webhook():
raw_body = request.get_data(as_text=True) # raw body, no parsing
received = request.headers.get("Signature", "")
if not is_valid(received, raw_body):
return "", 401
event = request.get_json() # parse only after validating
print(event)
return "", 200
<?php
const SIGNATURE_KEY = 'YOUR_SECRET_TOKEN';
const WEBHOOK_URL = 'https://seu-dominio.com/webhooks/qitech';
const HTTP_METHOD = 'HTTP_VERB'; // see the table above
function calculateSignature(string $endpoint, string $method, string $payload): string
{
return hash_hmac('sha1', $endpoint . $method . $payload, SIGNATURE_KEY);
}
function isValid(string $receivedSignature, string $rawBody, string $url, string $method): bool
{
$expected = calculateSignature($url, $method, $rawBody);
// hash_equals prevents timing attacks
return hash_equals($expected, $receivedSignature);
}
$rawBody = file_get_contents('php://input'); // raw body, no parsing
$received = $_SERVER['HTTP_SIGNATURE'] ?? '';
if (!isValid($received, $rawBody, WEBHOOK_URL, HTTP_METHOD)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true); // parse only after validating
http_response_code(200);
const crypto = require("crypto");
const express = require("express");
const SIGNATURE_KEY = "YOUR_SECRET_TOKEN";
const WEBHOOK_URL = "https://seu-dominio.com/webhooks/qitech";
const HTTP_METHOD = "HTTP_VERB"; // see the table above
function calculateSignature(endpoint, method, payload) {
return crypto
.createHmac("sha1", SIGNATURE_KEY)
.update(endpoint + method + payload, "utf8")
.digest("hex");
}
function isValid(receivedSignature, rawBody, url, method) {
const expected = calculateSignature(url, method, rawBody);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(receivedSignature, "utf8");
// timingSafeEqual requires equal-length buffers
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw preserves the raw body — do NOT use express.json() on this route
app.use(
"/webhooks/qitech",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const received = req.get("Signature") || "";
if (!isValid(received, rawBody, WEBHOOK_URL, HTTP_METHOD)) {
return res.sendStatus(401);
}
const event = JSON.parse(rawBody); // parse only after validating
console.log(event);
res.sendStatus(200);
},
);
app.listen(3000);
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class WebhookSignature {
private static final String SIGNATURE_KEY = "YOUR_SECRET_TOKEN";
private static final String WEBHOOK_URL = "https://seu-dominio.com/webhooks/qitech";
private static final String HTTP_METHOD = "HTTP_VERB"; // see the table above
public static String calculateSignature(String endpoint, String method, String payload)
throws Exception {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(
SIGNATURE_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
byte[] digest = mac.doFinal(
(endpoint + method + payload).getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
public static boolean isValid(String receivedSignature, String rawBody,
String url, String method) throws Exception {
String expected = calculateSignature(url, method, rawBody);
// MessageDigest.isEqual prevents timing attacks
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
receivedSignature.getBytes(StandardCharsets.UTF_8));
}
}
In Spring Boot, receive the body as a String to preserve the original bytes:
@RestController
public class WebhookController {
@RequestMapping(value = "/webhooks/qitech")
public ResponseEntity<Void> receive(
@RequestBody String rawBody, // raw body, no parsing
@RequestHeader(value = "Signature", required = false) String signature)
throws Exception {
if (signature == null
|| !WebhookSignature.isValid(signature, rawBody,
WebhookSignature.WEBHOOK_URL, WebhookSignature.HTTP_METHOD)) {
return ResponseEntity.status(401).build();
}
// parse only after validating (ex.: com Jackson)
return ResponseEntity.ok().build();
}
}
using System;
using System.Security.Cryptography;
using System.Text;
public static class WebhookSignature
{
private const string SignatureKey = "YOUR_SECRET_TOKEN";
public const string WebhookUrl = "https://seu-dominio.com/webhooks/qitech";
public const string HttpMethod = "HTTP_VERB"; // see the table above
public static string CalculateSignature(string endpoint, string method, string payload)
{
using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(SignatureKey));
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(endpoint + method + payload));
return Convert.ToHexString(digest).ToLowerInvariant();
}
public static bool IsValid(string receivedSignature, string rawBody,
string url, string method)
{
var expected = CalculateSignature(url, method, rawBody);
// FixedTimeEquals prevents timing attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(receivedSignature));
}
}
In ASP.NET Core, read the raw body before any deserialization:
app.MapMethods("/webhooks/qitech", new[] { WebhookSignature.HttpMethod }, async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(); // raw body, no parsing
var received = request.Headers["Signature"].ToString();
if (!WebhookSignature.IsValid(received, rawBody,
WebhookSignature.WebhookUrl, WebhookSignature.HttpMethod))
{
return Results.Unauthorized();
}
// parse only after validating
return Results.Ok();
});
- Was the body re-serialized? This is the most frequent cause. Use the raw body.
- Is the URL identical? A trailing slash added or removed changes the signature. Use exactly the URL configured with support.
- Is the method correct and uppercase? For these notifications it is
POST. - Is the concatenation order right? It is
endpoint + method + payload, in that order. - Is the digest lowercase hexadecimal? It is not Base64.
Request
The request has the format below and notifies that the analysis has been completed. The request uses the HTTP POST method and the request body is sent as UTF-8 encoded text.
Success Webhook
curl --location 'YOUR-ENDPOINT-HERE' \
--header 'Signature: CALCULATED-HASH-HMAC' \
--data '{"id": "e314ffci-14f3-41a1-ad5d-c9c18782jhfe", "document": {"analysis_result": {...}, "validation_status": "valid"}, "status": "successful", "status_reason": "", "status_description": "Sucessfull Analysis"}'
Error Webhook
curl --location 'YOUR-ENDPOINT-HERE' \
--header 'Signature: CALCULATED-HASH-HMAC' \
--data '{"id": "e314ffci-14f3-41a1-ad5d-c9c18782jhfe", "status": "bad_request", "status_reason": "missing_information
", "status_description": "The document is missing required information."}'