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
add /
search calls that trigger them.
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 monthly write or query quota crosses 80%: sent by the memory write (REST, hosted MCP or a batch item) or the read (REST or hosted MCP) that crosses it. An edit or a fact write that crosses it sends nothing that month. | used, limit, percent, and kind: "queries" on the query quota |
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.
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 and fact.invalidated,
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 or fact 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 enqueue a sample
memory.created delivery
({"event": "memory.created", "data": {"id": "mem_test", "test": true}},
sent even if the endpoint is not subscribed to that event) and confirm your receiver
and signature check work before you depend on real traffic. Up to 20 tests per hour.
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 keep the old key, the literal
whsec_... string, until you recreate them; the endpoint list
marks them with legacy_signature: true. Endpoints created
from 2026-09-28 on use the key above, so the official libraries verify
them as they are.
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
add/searchrequest 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 and fact 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