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 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

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 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:

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 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.

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 add / search 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 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