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:
- A domain event (e.g. an
imaginejob completing) writes one frozen event row. The signed body is fixed at this moment and never re-serialized. - A background sweeper fans out each event to every active endpoint subscribed to it, creating one delivery per
(event × endpoint). - 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.
- A
2xxmarks 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.
/api/developer/webhooksBody parameters (JSON, cookie session + CSRF token required):
| Name | Type | Required | Description |
|---|---|---|---|
appId | string | required | The app that will own the endpoint. Must be an app you own. |
url | string | required | The public HTTPS URL that will receive deliveries. Validated against the rules below. |
description | string | optional | Human label for the endpoint (max 255 chars). |
csrf | string | required | CSRF 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:
| Rule | Rejected when… |
|---|---|
Scheme must be https | The URL is http:// or any non-HTTPS scheme. |
| No embedded credentials | The URL contains user:pass@ userinfo. |
| Host must resolve | DNS returns no A/AAAA records. |
| Every resolved IP must be public | Any record resolves to a private, loopback, link-local or otherwise internal address. |
| Port must be 443 or non-privileged | The 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:
-
Echo the challenge (automatic). Respond
200and include thechallengevalue somewhere in your response body. Krallo detects the echo and flips the endpoint toactivewith no further action. -
Submit the challenge. Capture the
challengevalue your server received and confirm it in the console, or POST it back:
A matching challenge (compared in constant time) returnsPOST /api/developer/webhooks/verify Content-Type: application/json { "id": "01JBK7Q2...", "challenge": "9f3c1a7b0e5d4a28c6f1b0...", "csrf": "<token>" }200 {"ok":true}and activates the endpoint; a mismatch returns400 verification_failed.
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:
/api/developer/webhooks/subscribe| Name | Type | Required | Description |
|---|---|---|---|
id | string | required | The endpoint id. |
eventType | string | required | A type from the event catalog (e.g. imagine.completed). Unknown types return 422 unknown_event. |
guildId | string | conditional | Required for guild-scoped events, omitted for user-scoped events. A wrong scope returns 422 invalid_event_scope. |
csrf | string | required | CSRF token. |
Each event type belongs to a scope family that governs how you may subscribe and how Krallo authorizes delivery:
| Scope family | Subscription | Authorization at fan-out |
|---|---|---|
user | Must be user-scoped (no guildId). | Own-data-only: the event owner must be one of your linked identities. |
guild | Must name a guildId. | You must currently hold admin on that guild — re-validated at delivery time, so losing admin stops deliveries. |
either | User- 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"
}
}
| Field | Type | Description |
|---|---|---|
id | string | The unique event id (ULID). Stable across every retry. Matches the X-Krallo-Event-Id header — use it for deduplication. |
type | string | The event type, e.g. imagine.completed. |
api_version | string | The payload contract version pinned into every envelope. Currently 2026-07-12. |
created_at | integer | Unix timestamp (seconds) when the event was produced. |
scope | string | "user" or "guild". |
resource_id | string | The id of the resource that changed. Present only when the event has a subject. |
data | object | The 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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Krallo-Webhooks/1.0 |
X-Krallo-Signature | t=<unix-seconds>,v1=<hmac-sha256-hex> — verify this before trusting the body. |
X-Krallo-Event | The event type, e.g. imagine.completed. |
X-Krallo-Event-Id | The event id — stable across retries. Deduplicate on this. |
X-Krallo-Webhook-Id | The delivery id — identifies the specific delivery row (also stable across its retries). |
Idempotency-Key | Mirrors 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.
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:
- returns a non-
2xxstatus (including any3xxredirect — redirects are not followed); - does not respond within the 10-second delivery timeout; or
- is unreachable (connection refused, TLS failure, DNS failure).
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):
/api/v1/webhooks/deliveries/{id}/replaycurl -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
| Status | Receives deliveries? | Meaning |
|---|---|---|
unverified | No | Created but not yet through the ownership handshake. |
active | Yes | Verified and eligible for fan-out. |
paused | No | Temporarily suspended by you. |
disabled | No | Auto-disabled after too many consecutive failures, or disabled manually. |
Limits
| Limit | Value |
|---|---|
| Endpoints per app | 25 |
| Delivery attempts before dead-letter | 12 |
| Consecutive failures before auto-disable | 20 |
| Retry backoff | Exponential, ~10s doubling to a few-minute ceiling |
| Delivery timeout | 10 seconds |
| Response body read | Up to 64 KB (≈1 KB snippet retained) |
| Delivery history retention | 90 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
- Signature verification — verify
X-Krallo-Signatureand reject replays. - Event catalog — the subscribable event types and their
datapayloads. - Webhook deliveries API — list, inspect and replay deliveries over the bearer API.
- Errors — status codes and error slugs used across the platform.