Webhooks

Webhooks

Receive AI events at your own HTTPS endpoints — signed, retried, at-least-once.

Instead of polling, register an HTTPS URL and Krallo will POST a signed JSON event to it whenever something you subscribed to happens — for example when an image-generation job finishes. Every delivery carries an HMAC-SHA256 signature, is attempted at least once with automatic retries, and — if your endpoint stays unreachable — is parked in a dead-letter state you can replay from the console or the deliveries API.

You manage endpoints, subscriptions and verification in the Developer console (cookie-authenticated). The bearer /api/v1 surface exposes the read-only delivery log and replay. If the console or these routes return 404, the developer platform is not yet enabled for your instance.

How delivery works

Krallo uses a durable transactional-outbox pipeline, so a webhook can never break the operation that produced it:

  1. A domain event (e.g. an imagine job completing) writes one frozen event row. The signed body is fixed at this moment and never re-serialized.
  2. A background sweeper fans out each event to every active endpoint subscribed to it, creating one delivery per (event × endpoint).
  3. The sweeper claims due deliveries under a short lease and POSTs each through an SSRF-guarded, IP-pinned sender. Deliveries to the same endpoint are serialized; different endpoints run in parallel.
  4. A 2xx marks the delivery delivered. Anything else is retried with exponential backoff until it succeeds or exhausts its attempts, at which point it is dead-lettered.

Because fan-out and retries are decoupled from the source event, delivery is at-least-once: your endpoint may occasionally receive the same event more than once. Deduplicate on the X-Krallo-Event-Id header (see Idempotency and ordering).

Register an endpoint

Create an endpoint on your app in the console, or from the cookie-authenticated portal API. The URL is SSRF-validated before anything is stored, and a signing secret is minted and returned exactly once.

POST/api/developer/webhooks

Body parameters (JSON, cookie session + CSRF token required):

NameTypeRequiredDescription
appIdstringrequiredThe app that will own the endpoint. Must be an app you own.
urlstringrequiredThe public HTTPS URL that will receive deliveries. Validated against the rules below.
descriptionstringoptionalHuman label for the endpoint (max 255 chars).
csrfstringrequiredCSRF token bound to your session cookie.

URL requirements

The URL guard rejects anything that isn't a safe public target. A blocked URL returns 422 url_blocked with a message:

RuleRejected when…
Scheme must be httpsThe URL is http:// or any non-HTTPS scheme.
No embedded credentialsThe URL contains user:pass@ userinfo.
Host must resolveDNS returns no A/AAAA records.
Every resolved IP must be publicAny record resolves to a private, loopback, link-local or otherwise internal address.
Port must be 443 or non-privilegedThe port is below 1024 and is not 443 (e.g. :22, :6379). High HTTPS ports such as :8443 are allowed.

The guard resolves-and-pins the host at validation time and hands the exact IPs to the sender, so DNS cannot be rebound to an internal address between the check and the connection. The sender also does not follow redirects — a 3xx to an internal target is treated as a failed delivery, never chased.

A successful create returns 201 with the endpoint and its one-time signing secret:

{
  "ok": true,
  "endpoint": {
    "id": "01JBK7Q2W8Z0M4X6R9V3T5N1D7",
    "appId": "01JBK6...",
    "url": "https://example.com/krallo/webhooks",
    "status": "unverified",
    "secretPrefix": "whsec_1a2b3c4d",
    "apiVersion": "v1",
    "verifiedAt": null,
    "consecutiveFails": 0,
    "subscriptions": []
  },
  "secret": "whsec_kQ8f...redacted...9pZ2"
}

The secret (prefixed whsec_) is returned only in this response. Store it securely — you use it to verify signatures. Only a non-secret fingerprint (secretPrefix) is retained. If it leaks, rotate it (POST /api/developer/webhooks/secret/rotate) and the old secret stops signing immediately.

Verify endpoint ownership

An endpoint is created unverified and receives no deliveries until it passes an ownership-and-reachability handshake. This proves you control the URL — not just that it looks public.

Immediately after creation (and on demand via POST /api/developer/webhooks/ping), Krallo sends a signed challenge to your URL:

POST https://example.com/krallo/webhooks
Content-Type: application/json
X-Krallo-Event: endpoint.verification
X-Krallo-Signature: t=1720000000,v1=<hmac-sha256-hex>

{"type":"endpoint.verification","challenge":"9f3c1a7b0e5d4a28c6f1b0..."}

There are two ways to complete verification:

The verification ping is signed with the same secret as real events, so you can validate it with the same code path. Once verified, verifiedAt is set and the endpoint begins receiving fan-out.

Subscribe to events

A verified endpoint still receives nothing until you subscribe it to one or more event types. Add a subscription from the console or the portal API:

POST/api/developer/webhooks/subscribe
NameTypeRequiredDescription
idstringrequiredThe endpoint id.
eventTypestringrequiredA type from the event catalog (e.g. imagine.completed). Unknown types return 422 unknown_event.
guildIdstringconditionalRequired for guild-scoped events, omitted for user-scoped events. A wrong scope returns 422 invalid_event_scope.
csrfstringrequiredCSRF token.

Each event type belongs to a scope family that governs how you may subscribe and how Krallo authorizes delivery:

Scope familySubscriptionAuthorization at fan-out
userMust be user-scoped (no guildId).Own-data-only: the event owner must be one of your linked identities.
guildMust name a guildId.You must currently hold admin on that guild — re-validated at delivery time, so losing admin stops deliveries.
eitherUser- or guild-scoped.Whichever applies to the underlying resource.

Subscribing to a guild event you don't administer returns 403 not_guild_admin. The current catalog is AI-focused — see the event catalog for the live list and each event's data shape.

The event envelope

Every delivery body is a stable JSON envelope. The event-specific payload lives under data:

{
  "id": "01JBK9T4M2X7Q0R8V6N3D5Z1AB",
  "type": "imagine.completed",
  "api_version": "2026-07-12",
  "created_at": 1720000000,
  "scope": "user",
  "resource_id": "6f3a2c1e-9b0d-4e21-8a77-1c2d3e4f5a6b",
  "data": {
    "...": "event-specific fields"
  }
}
FieldTypeDescription
idstringThe unique event id (ULID). Stable across every retry. Matches the X-Krallo-Event-Id header — use it for deduplication.
typestringThe event type, e.g. imagine.completed.
api_versionstringThe payload contract version pinned into every envelope. Currently 2026-07-12.
created_atintegerUnix timestamp (seconds) when the event was produced.
scopestring"user" or "guild".
resource_idstringThe id of the resource that changed. Present only when the event has a subject.
dataobjectThe frozen event-specific payload. Its shape is documented per event in the event catalog.

Request headers

Every POST Krallo makes to your endpoint carries this fixed set of headers:

HeaderValue
Content-Typeapplication/json
User-AgentKrallo-Webhooks/1.0
X-Krallo-Signaturet=<unix-seconds>,v1=<hmac-sha256-hex> — verify this before trusting the body.
X-Krallo-EventThe event type, e.g. imagine.completed.
X-Krallo-Event-IdThe event id — stable across retries. Deduplicate on this.
X-Krallo-Webhook-IdThe delivery id — identifies the specific delivery row (also stable across its retries).
Idempotency-KeyMirrors the event id, for consumers that key idempotency off a standard header.

Verify the signature

The signature scheme is Stripe-style HMAC-SHA256. The signed message is the exact timestamp and raw body, joined by a dot: t + "." + rawBody. Recompute the HMAC with your endpoint secret over the raw request bytes (before any JSON parsing), compare in constant time, and reject a stale t to defeat replays.

Node.js
const crypto = require("crypto");

// secret = the whsec_... value shown once at endpoint creation
function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map(p => p.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
}

See Signature verification for the full walkthrough, the replay window, and examples in more languages.

Responding to deliveries

To acknowledge a delivery, return any 2xx status. Krallo treats a delivery as failed when your endpoint:

Krallo reads up to 64 KB of your response and stores a ~1 KB snippet for debugging in the delivery attempt log. Return quickly and do heavy work asynchronously — acknowledge first, process after.

Retries and dead-letter

A failed delivery is retried with exponential backoff. Delays grow from roughly 10 seconds and double on each attempt (10s, 20s, 40s, …) up to a ceiling of a few minutes; the schedule is stored in the database, so retries survive restarts. A delivery is attempted up to 12 times.

After the final attempt fails, the delivery is moved to the terminal dead state (dead-lettered) and its endpoint's consecutive-failure counter increments. A successful delivery resets that counter. If an endpoint accumulates 20 consecutive failed deliveries, it is automatically disabled and stops receiving fan-out until you re-enable it.

Delivery is re-authorized at send time. A replayed or retried delivery is dropped (dead-lettered with a reason such as endpoint_inactive, guild_admin_revoked, or secret_undecryptable) if the endpoint was disabled, the guild admin grant was revoked, or the signing secret can no longer be decrypted — a webhook cannot outlive the authorization that produced it.

Idempotency and ordering

Because delivery is at-least-once, design your handler to be idempotent. Persist the X-Krallo-Event-Id (equal to the envelope id) and ignore an event id you have already processed. A single logical change is delivered under one stable event id no matter how many times it is retried.

Deliveries to a single endpoint are processed serially, but retries and backoff mean events can still arrive out of order. Do not rely on receipt order — use created_at and the resource's own state to order effects.

Replaying deliveries

Any delivery — dead-lettered or already delivered — can be re-driven through the same pipeline.

From the console: open the delivery in the Deliveries log and select Replay.

From the bearer API (requires the webhooks:write scope):

POST/api/v1/webhooks/deliveries/{id}/replay
curl -X POST https://exe.krallo.com/api/v1/webhooks/deliveries/01JBK.../replay \
  -H "Authorization: Bearer krallo_live_..."

Replay returns 202 Accepted and resets the delivery to pending; the sweeper re-sends it on its next tick. Replay is scoped to your own apps — another developer's delivery id returns 404 not_found. A delivery that is currently in flight (sending) cannot be replayed and returns 409 delivery_in_flight; wait for it to settle, then retry.

To list and inspect deliveries, see the Webhook deliveries API reference.

Endpoint statuses

StatusReceives deliveries?Meaning
unverifiedNoCreated but not yet through the ownership handshake.
activeYesVerified and eligible for fan-out.
pausedNoTemporarily suspended by you.
disabledNoAuto-disabled after too many consecutive failures, or disabled manually.

Limits

LimitValue
Endpoints per app25
Delivery attempts before dead-letter12
Consecutive failures before auto-disable20
Retry backoffExponential, ~10s doubling to a few-minute ceiling
Delivery timeout10 seconds
Response body readUp to 64 KB (≈1 KB snippet retained)
Delivery history retention90 days

These values are platform defaults and may be tuned per instance. Build your consumer to tolerate duplicate deliveries, out-of-order arrival, and occasional dead-letters rather than depending on exact numbers.

Next steps