Korely

Events

Webhooks

Get a signed POST the moment a memory is created, a fact stops being current, or your account is about to hit a quota, so your product reacts in real time instead of polling.

Register an endpoint and Korely delivers an HTTP POST for the events you subscribe to. Each delivery is signed per the Standard Webhooks spec (HMAC-SHA256), retried for three days until your endpoint accepts it, and carries a stable id so you can dedupe. Webhooks are best-effort and fully decoupled, they never block or slow down the writes that trigger them (add, edits, fact writes).

Why fact.invalidated is the one that matters. When a new memory contradicts something Korely already knew, the old typed fact is superseded (invalidated, never silently overwritten) and you get pushed the event, so any cache, search index, or downstream prompt you built stays honest. A plain vector store has no typed facts to invalidate, so it has nothing to push. This is the moat, delivered as an event.

Events

EventFires whenData fields
memory.created A memory is added, via POST /v1/memories, a batch import item or the hosted MCP's korely_add. id, user_id, agent_id
fact.invalidated A typed fact stops being current: a newer fact (from a memory, POST /v1/facts or PATCH /v1/facts/{id}) superseded it, with invalidated_by the new id; it was closed with POST /v1/facts/{id}/forget (invalidated_by: null); or an edit of its memory no longer states it (invalidated_by: null, plus reason: "memory_updated" and memory_id). Not sent when DELETE /v1/memories/{id} closes facts, nor for erasures. fact_id, invalidated_by, at; on an edit also reason, memory_id
quota.warning The month's writes cross 80% of the plan's writes (top-ups included). Sent by the write that crosses the line: a memory write (REST or hosted MCP), a batch import when it is accepted (all its items are counted then), a memory edit, a fact write or a fact correction. Cloud only. Reads never send it. kind ("writes"), used, limit, percent, resets_at
key.expiring An API key with an expiry date stops working within 14 days. Sent once per key, to the endpoints of the key's project and to those that take every project. Keys that an OAuth connection renews by itself every hour are left out. key_id, name, last_four, expires_at, days_left
key.expired That key has stopped working. Sent once, on the day it expires, also for a key that was already announced with key.expiring. key_id, name, last_four, expires_at, days_left (0)

Subscribe to specific events, or use * to receive all of them. The body of every delivery is a JSON envelope: a top-level event string and a data object whose shape depends on the event.

Payloads

{
"event": "memory.created",
"data": {
"id": "mem_8f2c1a",
"user_id": "u_42",
"agent_id": "support-bot"
},
"project_id": "3f6c1e2a-9b4d-4c1f-8e2a-6d5b7c9e0f12"
}

Register an endpoint

Webhook endpoints are managed from your Korely dashboard. Add the URL that should receive deliveries, pick the events (or *) and, when the account has more than one project, the project it listens to, and Korely returns a signing secret of the form whsec_.... Store that secret, you need it to verify every delivery. If it ever leaks, delete the endpoint and create it again: the new endpoint gets a new secret. Endpoints are created, listed, tested, deleted, and enabled again after Korely switches one off; there is no separate rotate or edit action.

The URL is an https:// address on the public internet. At every delivery Korely looks your host up once, checks every address it resolves to, and connects to one of those addresses. A host that resolves to a private, loopback, link-local or reserved address, even one among public ones, is refused when you add it, and an attempt that finds one sends nothing: it fails, and is retried like any other failure. TLS and the Host header still use your hostname, so your certificate must be valid for it.

A host that does not resolve within 5 seconds is refused, with a 400, when you add the endpoint or enable it again. Adding, testing or enabling an endpoint answers 429 rate_limit_exceeded, with Retry-After, while two tests or address checks of your account are still under way.

You can register up to 16 endpoints per account, and each gets its own secret. An endpoint bound to a project receives that project's memory.created, fact.invalidated, key.expiring and key.expired (about the keys of that project), plus the account's quota.warning (the quota belongs to the account). An endpoint with no project, the default and every endpoint created before projects could be chosen, receives the events of every project. For staging and production, create one endpoint per project. A memory, fact or key delivery carries project_id, the id of the project the event is about (the dashboard's Projects page shows it), so a receiver of every project can tell them apart; the quota warning, which belongs to the account, carries none. Deleting a project deletes the endpoints bound to it. Use the dashboard's Send test action on an enabled endpoint to send one signed delivery at once and see your receiver's answer: {"event": "ping", "test": true, "data": {"message": "..."}, "project_id": ...} (project_id is null for an endpoint that takes every project). Acknowledge it like any event you do not act on, and confirm your receiver and signature check work before you depend on real traffic.

Verify the signature

Every delivery carries three headers. Always verify before you trust the body:

HeaderValue
webhook-idUnique id for this delivery. Use it to dedupe replays.
webhook-timestampUnix seconds when Korely signed the delivery.
webhook-signatureA space-separated list of signatures, each formatted v1,<base64>. Split it and accept the delivery if any v1 signature matches.

The signature is an HMAC-SHA256 over the exact string {webhook-id}.{webhook-timestamp}.{raw-body}, keyed by the base64-decoded part after whsec_ in your secret, base64-encoded and prefixed with v1,. Compute it over the raw request body, byte-for-byte, before any JSON parsing or re-serialization, re-encoding the JSON will change the bytes and break the check.

The simplest way is the official standardwebhooks library, which does all of this and rejects a timestamp more than 5 minutes from now:

# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
wh = Webhook("whsec_...") # from your dashboard
# Raises on a bad signature or a timestamp more than 5 minutes off.
payload = wh.verify(raw_body, headers)

Or verify by hand, with the same 5-minute tolerance:

import base64, hashlib, hmac, time
WEBHOOK_SECRET = "whsec_..." # from your dashboard
TOLERANCE_SECONDS = 5 * 60
def verify(headers, raw_body: bytes) -> bool:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(WEBHOOK_SECRET.removeprefix("whsec_"))
signed_content = f"{msg_id}.{ts}.".encode("utf-8") + raw_body
expected = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode("ascii")
# The header is a space-separated list of "v1,<base64>" signatures.
for part in headers["webhook-signature"].split(" "):
version, _, sig = part.partition(",")
if version == "v1" and hmac.compare_digest(sig, expected):
return True
return False

Endpoints created before 2026-09-28 (the endpoint list and the Webhooks page mark them with legacy_signature: true) have a secret that is not valid for the spec, and they are not sent. Their events are still queued, and each attempt is refused with the reason: the Webhooks page counts these as failed attempts and shows the reason as the last error, and after the three days of attempts the endpoint is switched off, like any endpoint that accepts nothing (see Delivery & retries). To move one to a spec secret, recreate it on the Webhooks page (a new id, without the deliveries still queued). Endpoints created from 2026-09-28 on use the key above, so the official libraries verify them as they are.

On Self-hosted, the same endpoints are the ones created before Korely Agent 0.1.11, and one command gives such an endpoint a spec secret while keeping its id, its events and the deliveries still queued:

Terminal window
docker compose exec api python -m korely_agent.manage rotate-webhook-secret \
--tenant <tenant id> --id <endpoint id>

It prints the new secret once. Run within the three days of attempts, it sends what is queued under the new secret. There is one secret at a time: until your receiver has the new one it rejects the deliveries, and they are retried on the schedule below.

Delivery & retries

A delivery that fails is retried on the example schedule of the Standard Webhooks spec: ten attempts over a little more than three days, then the delivery is marked failed.

AttemptWaits after the previous failureTime since the first attempt
1sent at once0
25 seconds5 s
35 minutes5 min 5 s
430 minutes35 min 5 s
52 hours2 h 35 min
65 hours7 h 35 min
710 hours17 h 35 min
814 hours31 h 35 min
920 hours51 h 35 min
1024 hours75 h 35 min

Each wait is stretched or shortened at random by up to 10%, so the retries of deliveries that failed together do not all arrive together, and an attempt can start a little after its time. A receiver that is down for minutes or hours still gets every event, at the first attempt after it is back.

  • Success is any 2xx. Return any status from 200 to 299 quickly. Any other status, a timeout, or a connection error counts as a failure and is retried.
  • Redirects are failures. A 3xx is not followed. Register the final URL instead.
  • 15 seconds per attempt. Connecting, TLS, sending the request and receiving your status line and headers share one 15-second budget. Acknowledge with a 2xx immediately and do heavy processing in your own queue.
  • At-least-once. A delivery can arrive more than once (after a retry or a network hiccup). Dedupe on webhook-id and make your handler idempotent.
  • No strict ordering. Retries, and deliveries sent in parallel, mean a later event can arrive before an earlier one. Don't assume order, use the data in each event, or re-read from the API if you need the current state.
  • Decoupled by design. Events are queued to an outbox and delivered by a background worker, so a slow or down endpoint never affects the write request that produced the event.

Notes

  • Keep the secret server-side. Anyone with the whsec_ secret can forge a valid signature. Never ship it to a browser or mobile client.
  • One endpoint, and one secret, per project. Bind the staging endpoint to the staging project and the production endpoint to the production project: each receives only its own project's memory, fact and key events, and a leaked staging secret can't sign production traffic. An endpoint with no project receives every project's events.
  • Verify, then parse. Compute the signature over the raw body first; only parse the JSON once the signature matches.
  • Switched off after three days without a success. When an event has failed all ten attempts and your endpoint has accepted no delivery since that event was created, Korely switches the endpoint off. One event your receiver keeps refusing does not do it while other events get through. To bring it back, fix the receiver and select Enable on the endpoint in the dashboard: it keeps its URL and its secret, and its URL is checked again. A retry that falls while the endpoint is off is dropped; one still ahead when you enable it is sent.

Related