Korely

Events

Webhooks

Korely sends a signed POST to your server when something happens: a memory saved, a fact closed, an API key near expiry, or the month's writes at 80%.

On Cloud you manage webhooks only in the dashboard; there is no API for them. The dashboard is at agent.korely.ai (see Quickstart), and every Cloud plan has webhooks. Deliveries follow Standard Webhooks, an open spec, so ready-made libraries check them.

Quick start

You need a public https:// URL that reaches port 8000 of your receiver: a staging host, or on your machine a tunnel tool such as ngrok http 8000 (a free ngrok account), which prints one.

  1. In the dashboard, open Webhooks, enter your URL ending in /korely-webhook, leave every event ticked and select Add endpoint.
  2. Copy the signing secret (whsec_...) now: it is shown only once.
  3. Paste it into a receiver below and start it (python receiver.py, or the command in the Node.js code).
  4. On the endpoint's row, select the send icon (Send a test event to this endpoint).
# pip install flask standardwebhooks
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
wh = Webhook("whsec_...") # the signing secret from the dashboard
@app.post("/korely-webhook")
def korely_webhook():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "invalid signature", 400
print(event["event"], event["data"])
return "", 204
app.run(port=8000)

After the test, the endpoint's row shows one of these:

  • sent, 204: it works, and the receiver prints ping.
  • your server answered 400: usually a wrong secret. Copy all of it, whsec_ included.
  • your server answered another code, such as 404: the URL does not end in /korely-webhook.
  • Any other message: Korely could not reach your URL (see Endpoint rules).

For a real event, save a memory with an API key of the same account (see Add a memory):

# pip install korely-memory
from korely_memory import Korely
korely = Korely(api_key="kor_live_...")
korely.add("The customer prefers email.", user_id="customer-4812")

Within about 15 seconds the receiver prints memory.created. Nothing after 30 seconds? Look at the endpoint's row. It may show failed attempts and a Last failure, or Disabled (see Switched off). If the endpoint is set to Only one project, use a key of that project.

Terms

The Glossary defines the words used across the docs. These are the ones this page relies on.

  • Standard Webhooks: an open spec for signing and retrying webhooks, at standardwebhooks.com. Korely follows it, so the standardwebhooks libraries check deliveries for you.
  • Receiver: your server code that takes deliveries, such as the Flask or Express apps above.
  • Endpoint: one webhook URL you add, with its events, project and signing secret.
  • Delivery: one event sent to one endpoint, in up to ten attempts (POST requests).
  • Signing secret: the whsec_... value that proves a delivery comes from Korely.
  • HMAC-SHA256: a keyed hash. Only someone with the secret can produce it.
  • Base64: a text encoding of bytes, so a binary hash fits in a header.
  • Worker: a background job in Korely that sends queued deliveries.
  • Idempotent: handling a delivery twice has the same effect as handling it once.

Reference

Events

EventSent whenData fields
memory.created A memory is saved: POST /v1/memories, each memory of a batch (many sent in one call), or the add tool of the MCP server (korely_add). Sent before extraction, so the facts exist about ten seconds later. id, user_id, agent_id (null when not given)
fact.invalidated Korely closes a fact, because:
  • a newer fact replaced it;
  • a memory says it ended, as "Luca left Acme" ends "Luca works at Acme";
  • POST /v1/facts/{id}/forget closed it;
  • you updated the memory (PATCH) and the new text no longer says it (reason: "memory_updated").
Not sent when a delete or an erasure closes facts.
fact_id, invalidated_by (the replacing fact, or null), at; after an update also reason, memory_id
quota.warning Cloud only. The month's writes pass 80% of the plan's writes, plus any extra writes you added for that month. Every call that counts as a write can send it: memories, facts, updates, declared names. Reads never do. kind ("writes"), used, limit, percent, resets_at
key.expiring An API key has 14 days or less left. Checked hourly, sent once. key_id, name, last_four, expires_at, days_left
key.expired The key has stopped working. Sent once, within about an hour. Same as key.expiring, days_left is 0

Why fact.invalidated matters. A fact is closed when Korely decides it is no longer true; the event name says invalidated. Drop the old value from your caches. Korely can miss a change, though. After "Tom moved to Munich", "Tom lives in Berlin" can stay open, and no event comes.

The JSON body has event, data and project_id, the id of the project the event is about (or null). quota.warning has no project_id: the quota belongs to the account. Times are UTC dates and times, often with microseconds.

Payloads

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

The dashboard test sends {"event": "ping", "test": true, "data": {"message": "..."}, "project_id": ...}. Answer it with a 2xx.

Endpoint rules

  • Public addresses only. Korely refuses an address inside a private network or on the same machine, such as localhost or 10.x.x.x. When you add the URL, such an address gets a 400, even if the host also has public ones. A host's addresses (its DNS records) can change, so Korely looks the host up again at every attempt. If it then finds such an address, nothing is sent and the attempt is retried later.
  • Valid certificate. The https certificate must be valid for your hostname. A self-signed or expired one fails the attempt.
  • Limits. A host that does not resolve within 5 seconds gets a 400 on add or Enable. An account has up to 16 endpoints; one more answers 409 too_many_endpoints. At most two address checks or tests run at a time per account. Past that, adding, testing or enabling answers 429 rate_limit_exceeded: try again after Retry-After seconds.
  • Projects. With more than one project, choose Every project or Only one. An endpoint tied to one project receives that project's memory, fact and key events, plus the account's quota.warning. Deleting a project deletes its endpoints and their queued deliveries.
  • Secret. Each endpoint has its own. Keep it on your server: anyone with it can sign a fake delivery. On Cloud there is no rotate or edit action. If a secret leaks, delete the endpoint and add it again, with a new id and secret; its queued deliveries are lost.

Verify the signature

HeaderValue
webhook-idThe delivery's id, the same on every retry.
webhook-timestampWhen this attempt was signed, in seconds since 1 January 1970 (UTC).
webhook-signatureSignatures separated by spaces, each v1,<base64>.

The Quick start library does all of this. To compute a signature by hand:

  1. Take the part of the secret after whsec_ and base64-decode it. That is the key.
  2. Join the id, the timestamp and the raw body bytes, before any JSON parsing, with dots: {webhook-id}.{webhook-timestamp}.{raw body}.
  3. Compute the HMAC-SHA256 of that text with the key.
  4. Base64-encode the result and put v1, in front.
  5. Accept if it matches a signature in webhook-signature and the timestamp is within 5 minutes.
import base64, hashlib, hmac, time
WEBHOOK_SECRET = "whsec_..." # the signing secret from the dashboard
def verify(headers, raw_body: bytes) -> bool:
h = {k.lower(): v for k, v in headers.items()}
try:
msg_id, ts = h["webhook-id"], h["webhook-timestamp"]
signatures = h["webhook-signature"].split(" ")
too_old = abs(time.time() - int(ts)) > 5 * 60
key = base64.b64decode(WEBHOOK_SECRET.removeprefix("whsec_"))
except (KeyError, ValueError):
return False
if too_old:
return False
signed = f"{msg_id}.{ts}.".encode() + raw_body
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = ("v1," + base64.b64encode(digest).decode()).encode()
return any(hmac.compare_digest(s.encode(), expected) for s in signatures)

Both accept header names in any case, and return false on any failed check. With Express and express.raw, pass req.body.toString("utf8") as rawBody.

Delivery and retries

The worker looks for new deliveries every 15 seconds, so the first attempt usually comes within 15 seconds of the event. After each failure Korely waits longer: 5 seconds, then 5 minutes, 30 minutes, 2, 5, 10, 14, 20 and 24 hours. The tenth attempt comes about 75 h 35 min after the first, and is the last.

  • Success is any 2xx. Any other status, a timeout or a connection error is a failure. A 3xx is not followed: add the final URL.
  • Answer within 10 seconds. Once the request is sent, your server has 10 seconds to answer. Answer at once and do slow work in your own queue.
  • At least once. A delivery can arrive twice, for example when your 2xx is lost. Skip a webhook-id you already handled, and keep your handler idempotent.
  • No order. Deliveries go out in parallel and retries wait different times, so a later event can arrive first.

Switched off after a delivery runs out of attempts

A delivery can fail all ten attempts, over about 75 hours. If the endpoint accepted no other delivery in that time, Korely switches it off. A receiver that refuses one event but accepts the others stays on.

  • Nobody tells you. No email or event is sent. The row on the Webhooks page shows Disabled: nothing is sent to it.
  • Events are lost while it is off. New events are not queued for it, and retries that fall due are dropped. Enable does not bring them back.
  • To switch it back on, fix the receiver and select Enable on its row. It keeps its URL and secret, and the URL is checked again.

Catching up

Past deliveries cannot be listed or resent. To catch up, read the current state: GET /v1/memories and GET /v1/facts. Do the same after deletes and erasures.

Details

Skip this section on a first read.

  • Queuing. The event is saved together with the write, and the worker sends it later, so a slow endpoint never holds up a write. If saving the event fails, which is rare, the write still succeeds and the event is lost.
  • Key notices. key.expiring goes only to the endpoints that exist when the key enters its last 14 days. Keys created automatically when a tool signs in through OAuth are not announced.
  • Old endpoints. Endpoints created before 28 September 2026 (version 0.1.11 on Self-hosted) have a secret outside the spec, so every attempt is refused. Add them again for a valid secret.
  • Self-hosted. No quota.warning; nothing is sent unless the worker runs. To give any endpoint a new secret, keeping its id and queued deliveries, run:
Terminal window
docker compose exec api python -m korely_agent.manage rotate-webhook-secret \
--tenant <account id> --id <endpoint id>

manage list-tenants prints the account ids of your install, and manage list-webhooks the endpoint ids. The command prints the new secret once, and the old one stops working at once. Until your receiver has the new one, deliveries fail and are retried. Select Enable too if the endpoint was off.

Related