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.

HeaderExampleDescription
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:

HTTP
POST /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:

KeyMeaning
tThe Unix timestamp, in seconds, at which Krallo signed this delivery attempt.
v1The 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.

Text
signed_message = t + "." + raw_body
v1             = lowercase_hex( HMAC_SHA256( key = signing_secret, message = signed_message ) )

Concretely, for the example delivery above:

ComponentValue
t1720999999
signed message1720999999.{"id":"01J9Z8QK…","type":"imagine.completed",…}
keyyour endpoint signing secret (begins whsec_)
digestHMAC-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.

  1. Parse the header. Extract t and v1 from X-Krallo-Signature. If either is missing, or t is not an integer, reject the request.
  2. Enforce the replay window. Reject the request if t differs from your current time by more than the tolerance (recommended: 300 seconds). This bounds how long a captured request can be replayed. Because t is inside the signed message, an attacker cannot advance it without invalidating v1. 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-future t is a red flag.
  3. 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).
  4. Constant-time compare. Compare your computed digest against v1 using a constant-time comparison (for example, crypto.timingSafeEqual, hmac.compare_digest, or MessageDigest.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.js timingSafeEqual), 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.

Node.js
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.js
const 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.

Python
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:

Python
import 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.

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

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.

Troubleshooting

If verification is failing, work through these in order — the first two cause the vast majority of failures.

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