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 in the dashboard. The public API reference does not cover webhook management. 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.
- In the dashboard, open Webhooks, enter your URL ending in
/korely-webhook, leave every event ticked and select Add endpoint. - Copy the signing secret (
whsec_...) now: it is shown only once. - Paste it into a receiver below and start it (
python receiver.py, or the command in the Node.js code). - On the endpoint's row, select the send icon (Send a test event to this endpoint).
# pip install flask standardwebhooksfrom flask import Flask, requestfrom 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 printsping.your server answered 400: usually a wrong secret. Copy all of it,whsec_included.your server answeredanother code, such as404: 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-memoryfrom 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
standardwebhookslibraries 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
| Event | Sent when | Data 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:
| 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
localhostor10.x.x.x. When you add the URL, such an address gets a400, 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. The same goes for the addresses of Korely itself, the API and the dashboard: such an address gets a400when you add the URL, and nothing is sent to it. A webhook that already points there fails each attempt in the same way, and the rules in Delivery and retries apply. - 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
400on add or Enable. So does a host the resolver could not look up right now (a temporary DNS failure): the message says it could not be resolved now, rather than that it does not resolve, and the same address may work later (since 1.0.22). An account has up to 16 endpoints; one more answers409 too_many_endpoints. At most two address checks or tests run at a time per account. Past that, adding, testing or enabling answers429 rate_limit_exceeded: try again afterRetry-Afterseconds. - Projects. With more than one project, choose Every project or Only one; since 1.0.5 the dashboard asks you to choose when you add an endpoint. 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
| Header | Value |
|---|---|
webhook-id | The delivery's id, the same on every retry. |
webhook-timestamp | When this attempt was signed, in seconds since 1 January 1970 (UTC). |
webhook-signature | Signatures separated by spaces, each v1,<base64>. |
The Quick start library does all of this. To compute a signature by hand:
- Take the part of the secret after
whsec_and base64-decode it. That is the key. - Join the id, the timestamp and the raw body bytes, before any JSON parsing, with dots:
{webhook-id}.{webhook-timestamp}.{raw body}. - Compute the HMAC-SHA256 of that text with the key.
- Base64-encode the result and put
v1,in front. - Accept if it matches a signature in
webhook-signatureand 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
3xxis not followed: add the final URL. - Answer within about 10 seconds. Once the request is sent, your server has about 10 seconds to answer, and each attempt has 15 seconds in all (connecting, TLS, sending and reading). 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-idyou 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.expiringis sent when an endpoint takes it: active, subscribed tokey.expiringor*, and bound to the key's project or to none. A key is marked as announced only once a delivery is queued, so while no endpoint takes it the key waits, and an endpoint added within its last 14 days gets the notice at a later hourly round (since 1.0.8).key.expiredis sent once, on the day, heard or not. Keys created automatically when a tool signs in through OAuth are not announced. - Old endpoints. Endpoints created before 28 September 2026 have a secret outside the spec, so every attempt is refused. Add them again for a valid secret.
Related
- Temporal facts: how facts are closed
- Data & governance: quotas and regions