Skip to content

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.

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_body
signature = "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.

  1. Read the raw body before any JSON parsing. Parsing and re-serialising changes spacing, key order or escaping, and the signature no longer matches.

  2. Check the timestamp: reject the request if X-Kreezalid-Timestamp is 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).

  3. Compute the expected signature from the timestamp, a dot and the raw body, with your signing secret, and prefix it with sha256=.

  4. Compare in constant time with X-Kreezalid-Signature. A plain == leaks timing information. If the values differ, answer 401 and stop.

  5. Only then parse the JSON and process the event.

verify-kreezalid-webhook.mjs
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:

server.mjs
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);
});

These three functions are tested against signatures produced by Kreezalid.

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.

  • 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.
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.