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
| Event | Fires when | Data 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:
| Header | Value |
|---|---|
webhook-id | Unique id for this delivery. Use it to dedupe replays. |
webhook-timestamp | Unix seconds when Korely signed the delivery. |
webhook-signature | A 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 standardwebhooksfrom 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 dashboardTOLERANCE_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:
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.
| Attempt | Waits after the previous failure | Time since the first attempt |
|---|---|---|
| 1 | sent at once | 0 |
| 2 | 5 seconds | 5 s |
| 3 | 5 minutes | 5 min 5 s |
| 4 | 30 minutes | 35 min 5 s |
| 5 | 2 hours | 2 h 35 min |
| 6 | 5 hours | 7 h 35 min |
| 7 | 10 hours | 17 h 35 min |
| 8 | 14 hours | 31 h 35 min |
| 9 | 20 hours | 51 h 35 min |
| 10 | 24 hours | 75 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
200to299quickly. Any other status, a timeout, or a connection error counts as a failure and is retried. - Redirects are failures. A
3xxis 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-idand 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
- Add a memory, the call that fires
memory.created - Temporal facts, how supersession produces
fact.invalidated - Update a memory, correcting a memory and the invalidation it triggers
- Data & governance, quotas, residency, and the audit trail