Webhooks
Signature verification
Verify every webhook with HMAC-SHA256 before trusting it.
Every webhook Krallo delivers is signed. Each request carries an
X-Krallo-Signature header containing a timestamp and an HMAC-SHA256 signature
computed over the exact request body, keyed with your endpoint's signing secret. Because your
signing secret is known only to Krallo and to you, a valid signature proves the delivery
originated from Krallo and was not modified in transit.
Verifying the signature is mandatory. Your webhook URL is a public endpoint that anyone on the internet can POST to. Without verification, an attacker can forge events — fake image completions with attacker-controlled URLs — and your integration will act on them. This page documents the exact signing scheme and gives you a correct, constant-time verifier for Node.js, Python, and Java.
You must verify the HMAC over the raw request body bytes — the exact bytes you received on the wire. Do not re-serialize a parsed object (for example, JSON.stringify(req.body)): any difference in key order, whitespace, or Unicode escaping changes the bytes and the signature will not match. Capture the raw body before your framework parses it as JSON.
Delivery headers
Each delivery is an HTTP POST with a JSON body and the following
Krallo headers. The signature is in X-Krallo-Signature; the remaining headers help
you route and deduplicate the event.
| Header | Example | Description |
|---|---|---|
X-Krallo-Signature |
t=1720999999,v1=5257a869… |
The signed timestamp and HMAC. Parse and verify this on every request. See grammar below. |
X-Krallo-Event |
imagine.completed |
The event type. Mirrors type in the body. Use it to route without parsing JSON. |
X-Krallo-Event-Id |
01J9Z8QK7M3F2N5R8V6W1X4Y0T |
Stable, unique ID for the logical event (a ULID). Identical across every retry. Use this to deduplicate. |
Idempotency-Key |
01J9Z8QK7M3F2N5R8V6W1X4Y0T |
Same value as X-Krallo-Event-Id, provided under the conventional header name for convenience. |
X-Krallo-Webhook-Id |
01J9Z8QM4B0C7D9E1F3G5H7J9K |
The delivery record ID (a ULID). Identifies the specific endpoint delivery; also stable across retries of that delivery. |
Content-Type |
application/json |
The body is always a JSON object encoded as UTF-8. |
User-Agent |
Krallo-Webhooks/1.0 |
Fixed identifier for the delivery agent. Do not use it for authentication — it is trivially spoofable. |
A raw delivery looks like this:
HTTPPOST /webhooks/krallo HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: Krallo-Webhooks/1.0
X-Krallo-Signature: t=1720999999,v1=5257a869e7c1f0b4a2c9d8e6f1035b2a9c7e4d6f8a1b3c5d7e9f0a2b4c6d8e0f
X-Krallo-Event: imagine.completed
X-Krallo-Event-Id: 01J9Z8QK7M3F2N5R8V6W1X4Y0T
Idempotency-Key: 01J9Z8QK7M3F2N5R8V6W1X4Y0T
X-Krallo-Webhook-Id: 01J9Z8QM4B0C7D9E1F3G5H7J9K
{"id":"01J9Z8QK7M3F2N5R8V6W1X4Y0T","type":"imagine.completed","api_version":"2026-07-12","created_at":1720999998,"scope":"user","resource_id":"9f2c1b7a-4e6d-40b1-a0c3-f5e8d2b1a6c4","data":{"...":"..."}}
The signature header
X-Krallo-Signature is a comma-separated list of key=value pairs. Two
keys are present today:
| Key | Meaning |
|---|---|
t | The Unix timestamp, in seconds, at which Krallo signed this delivery attempt. |
v1 | The signature: the lowercase hex encoding of the HMAC-SHA256 digest (64 hex characters) for scheme version 1. |
Parse defensively. Split on ,, then split each part on the first =,
and read the value for t and v1. Ignore any keys you do not recognize —
future scheme versions may add pairs (for example, a v2) alongside v1,
and your parser should keep working. Treat a header with no t or no v1
as a verification failure.
The t in the signature is the delivery timestamp — it is re-stamped on every retry, so a redelivered event carries a fresh t and a fresh v1. This is separate from the created_at field inside the body, which is the original event time and never changes. Verify against t, deduplicate on the event ID.
How the signature is computed
Krallo builds the signed message by concatenating the timestamp, a single literal .
(period), and the raw JSON body — then computes an HMAC-SHA256 over the UTF-8 bytes of that
string, keyed with your endpoint's signing secret, and hex-encodes the result in lowercase.
signed_message = t + "." + raw_body
v1 = lowercase_hex( HMAC_SHA256( key = signing_secret, message = signed_message ) )
Concretely, for the example delivery above:
| Component | Value |
|---|---|
t | 1720999999 |
| signed message | 1720999999.{"id":"01J9Z8QK…","type":"imagine.completed",…} |
| key | your endpoint signing secret (begins whsec_) |
| digest | HMAC-SHA256, lowercase hex — this must equal v1 |
Your signing secret
Each webhook endpoint has its own signing secret, a high-entropy random string prefixed with
whsec_. The full secret is shown once, when you register the
endpoint (or when you rotate it) in the developer console — copy it then and store it as a
server-side secret. Afterwards the console only displays a non-secret fingerprint (also prefixed
whsec_) that identifies which secret is active without revealing it.
Use the endpoint's own secret to verify that endpoint's deliveries. If you rotate the secret, keep verifying against both the old and the new value until you are confident every in-flight delivery signed with the old secret has drained, then drop the old one.
Treat the signing secret like a password: never commit it to source control, never expose it to the browser, and never log it. Anyone who holds it can forge deliveries that pass verification.
Verify a signature
Verification is four steps. Perform all of them; skipping any one leaves a hole.
-
Parse the header. Extract
tandv1fromX-Krallo-Signature. If either is missing, ortis not an integer, reject the request. -
Enforce the replay window. Reject the request if
tdiffers from your current time by more than the tolerance (recommended: 300 seconds). This bounds how long a captured request can be replayed. Becausetis inside the signed message, an attacker cannot advance it without invalidatingv1. Krallo's reference verifier compares the absolute difference, so it also rejects timestamps that are too far in the future — clock skew aside, a far-futuretis a red flag. -
Recompute the HMAC over the raw body. Compute
HMAC-SHA256(secret, t + "." + rawBody)and hex-encode it in lowercase. Use the exact bytes you received, not a re-serialized parse (see the warning at the top of this page). -
Constant-time compare. Compare your computed digest against
v1using a constant-time comparison (for example,crypto.timingSafeEqual,hmac.compare_digest, orMessageDigest.isEqual). A byte-by-byte==leaks timing that can be used to forge a signature. In languages where the constant-time function throws on unequal-length inputs (notably Node.jstimingSafeEqual), guard the buffer lengths first and treat a length mismatch as a failed verification.
Only after all four steps pass should you act on the event. Respond 2xx to
acknowledge; any non-2xx (or a timeout) is treated as a failed delivery and retried
with backoff. See Webhooks overview for the retry and
dead-letter behavior.
Reference implementations
The verifiers below are complete and constant-time. Each takes the raw body, the raw
X-Krallo-Signature header, and the endpoint signing secret, and returns a boolean.
Adapt the framework glue (how you obtain the raw body) to your stack, but keep the crypto exactly
as written.
Node.js
Capture the raw body before JSON parsing. With Express, mount express.raw() on the
webhook route so req.body is a Buffer of the exact bytes received.
const crypto = require('crypto');
const REPLAY_WINDOW_SECONDS = 300;
/**
* Verify a Krallo webhook.
* @param {string|Buffer} rawBody the EXACT bytes received (not a re-serialized parse)
* @param {string} signatureHeader value of the X-Krallo-Signature header
* @param {string} secret the endpoint signing secret (whsec_...)
* @returns {boolean}
*/
function verifyKralloWebhook(rawBody, signatureHeader, secret) {
if (typeof signatureHeader !== 'string') return false;
// 1. Parse "t=<unix>,v1=<hex>"
let t = null;
let v1 = null;
for (const part of signatureHeader.split(',')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (key === 't') t = value;
else if (key === 'v1') v1 = value;
}
if (t === null || v1 === null) return false;
const ts = Number.parseInt(t, 10);
if (!Number.isFinite(ts)) return false;
// 2. Reject anything outside the replay window.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > REPLAY_WINDOW_SECONDS) return false;
// 3. Recompute HMAC-SHA256 over `t + "." + rawBody`, lowercase hex.
const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${body}`, 'utf8')
.digest('hex');
// 4. Constant-time compare. timingSafeEqual throws on unequal lengths — guard first.
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
module.exports = { verifyKralloWebhook };
Wiring it into an Express route, with event-ID deduplication:
Node.jsconst express = require('express');
const { verifyKralloWebhook } = require('./verify');
const app = express();
const SIGNING_SECRET = process.env.KRALLO_WEBHOOK_SECRET; // whsec_...
// Replace this in-memory set with a shared, durable store (e.g. Redis) in production.
const processed = new Set();
// express.raw() keeps req.body as the exact received bytes — required for HMAC.
app.post('/webhooks/krallo', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Krallo-Signature');
if (!verifyKralloWebhook(req.body, signature, SIGNING_SECRET)) {
return res.status(400).send('invalid signature');
}
// Deduplicate: retries reuse the same event ID.
const eventId = req.get('X-Krallo-Event-Id');
if (processed.has(eventId)) return res.status(200).end();
processed.add(eventId);
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event.type / event.data ...
res.status(200).end();
});
Python
Read the raw body as bytes — with Flask, use request.get_data(), not
request.json (which reparses and discards the original bytes).
hmac.compare_digest is constant-time and safely handles unequal-length inputs
without raising.
import hashlib
import hmac
import time
REPLAY_WINDOW_SECONDS = 300
def verify_krallo_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""Verify a Krallo webhook. `raw_body` must be the exact bytes received."""
if not signature_header:
return False
# 1. Parse "t=<unix>,v1=<hex>"
t = None
v1 = None
for part in signature_header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t":
t = value
elif key == "v1":
v1 = value
if t is None or v1 is None:
return False
try:
ts = int(t)
except ValueError:
return False
# 2. Reject anything outside the replay window.
if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS:
return False
# 3. Recompute HMAC-SHA256 over `t + "." + raw_body`, lowercase hex.
signed_message = t.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed_message, hashlib.sha256).hexdigest()
# 4. Constant-time compare (compare_digest is length-safe).
return hmac.compare_digest(expected, v1)
A Flask handler using it:
Pythonimport os
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["KRALLO_WEBHOOK_SECRET"] # whsec_...
processed = set() # use a durable store in production
@app.post("/webhooks/krallo")
def krallo_webhook():
raw = request.get_data() # exact bytes — do NOT use request.json here
signature = request.headers.get("X-Krallo-Signature", "")
if not verify_krallo_webhook(raw, signature, SIGNING_SECRET):
abort(400, "invalid signature")
event_id = request.headers.get("X-Krallo-Event-Id")
if event_id in processed: # retries reuse the same event ID
return "", 200
processed.add(event_id)
event = request.get_json() # safe to parse now that the signature checked out
# ... handle event["type"] / event["data"] ...
return "", 200
Java
This mirrors Krallo's own reference verifier.
MessageDigest.isEqual performs a constant-time comparison and, unlike Node's
timingSafeEqual, is length-safe (it never throws), so no explicit length guard is
needed. Pass the raw request body exactly as received — buffer it as a string or byte array
before any JSON parsing.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public final class KralloWebhookVerifier {
private static final long REPLAY_WINDOW_SECONDS = 300L;
private KralloWebhookVerifier() {}
/** Verify a Krallo webhook using the current wall clock. */
public static boolean verify(String secret, String rawBody, String signatureHeader) {
return verify(secret, rawBody, signatureHeader,
System.currentTimeMillis() / 1000L, REPLAY_WINDOW_SECONDS);
}
static boolean verify(String secret, String rawBody, String signatureHeader,
long nowSeconds, long windowSeconds) {
if (secret == null || rawBody == null || signatureHeader == null) return false;
// 1. Parse "t=<unix>,v1=<hex>"
long t = -1;
String v1 = null;
for (String part : signatureHeader.split(",")) {
String p = part.trim();
if (p.startsWith("t=")) {
try { t = Long.parseLong(p.substring(2).trim()); }
catch (NumberFormatException e) { return false; }
} else if (p.startsWith("v1=")) {
v1 = p.substring(3).trim();
}
}
if (t < 0 || v1 == null) return false;
// 2. Reject anything outside the replay window (absolute difference).
if (Math.abs(nowSeconds - t) > windowSeconds) return false;
// 3. Recompute HMAC-SHA256 over `t + "." + rawBody`, lowercase hex.
String expected = hmacHex(secret, t, rawBody);
// 4. Constant-time compare (isEqual is length-safe and never throws).
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
v1.getBytes(StandardCharsets.UTF_8));
}
private static String hmacHex(String secret, long tSeconds, String body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] h = mac.doFinal((tSeconds + "." + body).getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(h.length * 2);
for (byte x : h) sb.append(String.format("%02x", x));
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("HMAC verification failed", e);
}
}
}
Replay protection
A valid signature proves authenticity but not freshness — an attacker who captures a legitimate
delivery could resend those exact bytes. The timestamp closes that gap. Because t is
part of the signed message, it cannot be forged, and rejecting deliveries whose t
falls outside your tolerance bounds the replay window to that duration.
- Recommended tolerance: 300 seconds. Widen it only if you have a specific reason (e.g. large, unavoidable clock skew) — a wider window is a larger replay window.
- Keep clocks synced. Verify on a server whose clock is disciplined by NTP. If your host clock drifts, legitimate deliveries will fail the window check.
- Timestamp alone is not deduplication. A retried delivery within the window carries a fresh, valid
tand will pass the window check — that is intended. Deduplicate separately, on the event ID.
Deduplicate on the event ID
Krallo delivers at least once. A delivery that times out or returns a non-2xx is
retried, and network conditions can cause a delivery you already processed to arrive again. Every
retry of the same logical event carries the same X-Krallo-Event-Id (also mirrored as
the Idempotency-Key header and the id field in the body), so use that
value as your idempotency key.
- After verifying the signature, check whether you have already processed
X-Krallo-Event-Id. If so, return200and stop. - Otherwise, process the event and record the ID durably. Prefer a database unique constraint on the event ID (or a persistent cache) over an in-process set, so deduplication survives restarts and works across multiple instances.
- Make handling idempotent regardless: even with a dedup store, a crash between "acted on the event" and "recorded the ID" can produce a duplicate.
Troubleshooting
If verification is failing, work through these in order — the first two cause the vast majority of failures.
| Symptom | Likely cause |
|---|---|
| Signature never matches, timestamp is fine | You are hashing a re-serialized body. Hash the raw bytes, captured before JSON parsing (e.g. express.raw(), request.get_data()). Middleware or a proxy that reformats the body will also break the hash. |
| Everything rejected as out-of-window | Server clock skew. Sync with NTP. Confirm you are comparing seconds (not milliseconds) and using UTC. |
| Comparison throws instead of returning false | Node's crypto.timingSafeEqual throws on unequal-length buffers. Guard the lengths first, as shown in the Node.js sample. |
| Matches locally, fails in production | Wrong secret for the endpoint, or a stale secret after a rotation. Verify against the endpoint's current signing secret (or accept both old and new during a rotation window). Confirm the whsec_ fingerprint in the console matches the secret you deployed. |
v1 looks uppercase / wrong length in your comparison |
The digest must be lowercase hex, 64 characters. Compare hex-to-hex; do not decode one side to bytes and the other not. |
Related: Webhooks overview (event catalog, retries, dead-lettering) · Errors · Authentication for API requests.