Verifying signatures
Your webhook URL is public: anyone who finds it can send it requests. Kreezalid signs every webhook with a secret that only you and the marketplace know, so your endpoint can tell a genuine delivery from a forged or replayed one. Verify the signature on every request, before you trust anything in it.
How Kreezalid signs a request
Section titled “How Kreezalid signs a request”Each signed request carries three headers:
| Header | Example | Meaning |
|---|---|---|
X-Kreezalid-Timestamp |
1760385600 |
When this attempt was sent, in Unix seconds. Every attempt, retries included, has its own timestamp. |
X-Kreezalid-Signature |
sha256=76b07d4f…6eab91 |
sha256= followed by the hexadecimal HMAC-SHA256 of the signed content. |
X-Kreezalid-Event-Id |
918273 |
The event’s identifier, identical across retries. Use it to deduplicate. |
The signed content is the timestamp, a dot, and the raw request body, byte for byte:
signed_content = X-Kreezalid-Timestamp + "." + raw_bodysignature = "sha256=" + hex( HMAC_SHA256( key = signing_secret, message = signed_content ) )The signing secret is used as is, as a UTF-8 string. It looks like 48 hexadecimal characters, but do not hex-decode it.
Verify a request
Section titled “Verify a request”-
Read the raw body before any JSON parsing. Parsing and re-serialising changes spacing, key order or escaping, and the signature no longer matches.
-
Check the timestamp: reject the request if
X-Kreezalid-Timestampis more than five minutes away from your clock. This stops an attacker from replaying a request they captured. Keep your server’s clock synchronised (NTP). -
Compute the expected signature from the timestamp, a dot and the raw body, with your signing secret, and prefix it with
sha256=. -
Compare in constant time with
X-Kreezalid-Signature. A plain==leaks timing information. If the values differ, answer401and stop. -
Only then parse the JSON and process the event.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** * @param {Buffer} rawBody the request body, exactly as received * @param {Record<string, string | undefined>} headers lower-cased header names * @param {string} secret the webhook's signing secret */export function verifyKreezalidWebhook(rawBody, headers, secret) { const timestamp = headers['x-kreezalid-timestamp']; const signature = headers['x-kreezalid-signature']; if (!timestamp || !signature) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex');
const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && crypto.timingSafeEqual(a, b);}With Express, keep the body raw on the webhook route:
import express from 'express';import { verifyKreezalidWebhook } from './verify-kreezalid-webhook.mjs';
const app = express();
app.post('/kreezalid/webhooks', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyKreezalidWebhook(req.body, req.headers, process.env.KREEZALID_WEBHOOK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); queue.push({ id: req.headers['x-kreezalid-event-id'], event }); // process in the background res.sendStatus(204);});<?phpconst KREEZALID_TOLERANCE_SECONDS = 300;
function verifyKreezalidWebhook(string $rawBody, ?string $timestamp, ?string $signature, string $secret): bool{ if ($timestamp === null || $signature === null || !ctype_digit($timestamp)) { return false; } if (abs(time() - (int)$timestamp) > KREEZALID_TOLERANCE_SECONDS) { return false; }
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);}In a plain PHP endpoint:
<?phprequire __DIR__ . '/verify-kreezalid-webhook.php';
$rawBody = file_get_contents('php://input');$ok = verifyKreezalidWebhook( $rawBody, $_SERVER['HTTP_X_KREEZALID_TIMESTAMP'] ?? null, $_SERVER['HTTP_X_KREEZALID_SIGNATURE'] ?? null, getenv('KREEZALID_WEBHOOK_SECRET'),);if (!$ok) { http_response_code(401); exit;}
$event = json_decode($rawBody, true);// enqueue $event with $_SERVER['HTTP_X_KREEZALID_EVENT_ID'], then:http_response_code(204);In Laravel or Symfony, read the body with $request->getContent().
import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_kreezalid_webhook(raw_body: bytes, timestamp: str | None, signature: str | None, secret: str) -> bool: if not timestamp or not signature or not timestamp.isdigit(): return False if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False
expected = "sha256=" + hmac.new( secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw_body, hashlib.sha256, ).hexdigest()
return hmac.compare_digest(expected, signature)With Flask:
import osfrom flask import Flask, abort, requestfrom verify_kreezalid_webhook import verify_kreezalid_webhook
app = Flask(__name__)
@app.post("/kreezalid/webhooks")def kreezalid_webhook(): raw_body = request.get_data() # bytes, before any parsing if not verify_kreezalid_webhook( raw_body, request.headers.get("X-Kreezalid-Timestamp"), request.headers.get("X-Kreezalid-Signature"), os.environ["KREEZALID_WEBHOOK_SECRET"], ): abort(401) event = request.get_json() # enqueue event with request.headers["X-Kreezalid-Event-Id"] return "", 204These three functions are tested against signatures produced by Kreezalid.
Test vector
Section titled “Test vector”Use these values to unit-test your implementation. Disable the timestamp check for this test: the timestamp is in the past.
| Input | Value |
|---|---|
| Signing secret | 3f9a1c0b7d2e4f6a8b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e |
X-Kreezalid-Timestamp |
1760385600 |
| Raw body | {"type":"order.status.accepted","created":"2026-10-13 20:00:00","status":"success","data":{"object":"Order","id":4242,"number":"R-4242","status":"accepted","amount":"120.00","currency":"EUR"}} |
Expected X-Kreezalid-Signature |
sha256=76b07d4f3198f2dbf1928bd162dc3dff72a429708bb4de940957702b4b6eab91 |
To test end to end, click Send test notification next to the webhook in the back
office: the sample request is signed with the webhook’s real secret, exactly like a
delivery, with X-Kreezalid-Event-Id: test.
Managing the signing secret
Section titled “Managing the signing secret”- Where to find it. The back office shows the secret once, when signing is turned on for a webhook. Webhooks are created signed: to display the secret of a new webhook, click Disable signing, then Enable HMAC signing in the webhooks list (Settings → Notifications → Webhooks). The secret is kept while signing is off, so this shows the same secret again.
- Where to keep it. In your endpoint’s secret store or environment variables, never in front-end code or in a repository.
- One secret per webhook. Each webhook has its own secret. When one server receives
several events, give each webhook its own path (
/kreezalid/webhooks/orders-new,/kreezalid/webhooks/listings-update…) so the path tells you which secret to use. - Rotating it. Delete the webhook and create it again: the new webhook gets a new secret. Deliveries for that event stop between the deletion and the creation, so do it when it hurts least, and use the Admin API to catch up on what changed in between.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| Every signature fails | The body was parsed before verification (a JSON middleware ran first), or the secret was hex-decoded, trimmed or belongs to another webhook. |
| Signatures fail only for some payloads | Characters outside ASCII: compute the HMAC over the raw bytes, not over a decoded and re-encoded string. |
| Signatures fail behind a proxy or a gateway | It rewrote the body (decompression, charset conversion, pretty-printing). Verify the body your application receives, and configure the proxy to pass it through unchanged. |
| Valid signatures are rejected as stale | Your server’s clock drifts. Synchronise it, or widen the tolerance slightly, never beyond a few minutes. |
No X-Kreezalid-Signature header |
The webhook is unsigned. Enable signing for it in the back office. |