Keely for developers
Events and webhooks.
Keely writes an event when something in a person’s account changes, and delivers it to your webhook, signed. An event says what changed and links to it, never a number from a summary.
Updated
On this page
An app that wants to know at once subscribes. Keely writes an event when something in a person’s account changes, and delivers it to your webhook, signed; the same events can be read at GET /v1/events, to poll or to replay what a webhook missed. An event says what changed and links to it. It never carries a number from a summary: your app fetches the night, the day or the workout with the person’s token, under the same permissions as every other call.
The event types
Events are written only for people who have connected at least one registered app, and your app hears an event only when the person’s permissions hold its scope. A connection.* event and a test.ping go to one app alone.
| Type | When it fires | data | Scope |
|---|---|---|---|
night.created | The Keely app first backs a night up. It may still be corrected afterwards | { date, recovery_status } | nights |
night.updated | A night was corrected: a later sync, a settled start, an edit. Only when something but its updated_at changed | { date, recovery_status } | nights |
night.deleted | A night is gone | { date } | nights |
day.created | The Keely app first backs a day up | { date } | days |
day.updated | A day was corrected | { date } | days |
day.deleted | A day is gone | { date } | days |
workout.created | A workout first backed up, or one the person restored | { id, activity, started_at } | workouts |
workout.updated | A workout was corrected: its times, name, Load or zones | { id, activity, started_at } | workouts |
workout.removed | The person removed it | { id, activity, started_at }; { id } alone once the row itself is gone | workouts |
journal.entry.created | An entry was added, by the person, by Keely, by an assistant or by an app | { id, tag, source, starts_at } | journal |
journal.entry.updated | An entry changed | { id, tag, source, starts_at } | journal |
journal.entry.deleted | An entry was deleted | { id, tag } | journal |
journal.sharing_started | The person turned journal sharing on | {} | journal |
journal.sharing_ended | They turned it off. The entries’ going with it is silent | {} | journal |
connection.granted | The person connected your app | { scopes } | none: your app only |
connection.changed | They changed what it may read | { scopes, before } | none: your app only |
connection.revoked | They disconnected it, or deleted their Keely account | { reason }: disconnected or account_deleted | none: your app only |
person.history_changed | The digest: more than 25 events for one person were waiting at once | { since, events } | none: your app only |
test.ping | You sent a test from the portal | { subscription_id } | none: your app only |
connection.revoked means delete what you hold for that person (rule 3). Deleting their account sends it too, with reason: account_deleted, and nothing else: the nights and days going with the account are silent.
Timing
- New data goes at once. A
created,deleted,removed,sharing_*orconnection.*event is sent on the next minute. - A correction waits 10 minutes, and further corrections to the same night, day, workout or entry in that window fold into one event, which keeps the latest
occurred_atanddata. A correction to something whosecreatedhasn’t gone out yet folds into thecreated. Something made and unmade before anyone heard sends nothing. - A night’s first event comes when Keely first backs it up, and the night may still be corrected afterwards: a later sync brings the rest of the beats, the start settles, the person edits it. Read
night.createdas “a morning has landed” andnight.updatedas “read it again”. - A re-upload of the same numbers is silent. An
updatedevent is written only when something butupdated_atchanged, so a version that reads every stored day again sends nothing unless a number moved.
The digest
When more than 25 events for one person are waiting for your webhook at once (a restore, a new metrics version reading months again), they are replaced by one person.history_changed, with since, the earliest of them, and events, how many there were. Read GET /v1/changes?since=<since> and fetch what it lists; its link is in links.changes.
The webhook
Each delivery is one POST to your URL, with a JSON body:
{
"id": "evt_123",
"type": "night.created",
"person_id": "0b8f1a52-7e3c-4d0a-9c1b-2e6f3a4b5c6d",
"occurred_at": "2026-09-27T06:30:00Z",
"data": { "date": "2026-09-27", "recovery_status": "scored" },
"links": { "day": "https://meetkeely.com/api/v1/days/2026-09-27" },
"api_version": "1.0"
}
idis the event’s, stable across retries;person_idthe same idGET /v1/megives;occurred_atwhen the change happened (for a folded correction, the latest).linkspoints at what to fetch:dayfor a night or a day,workoutfor a workout,journalfor the journal’s entries,changesfor a digest; empty forconnection.*andtest.ping.- Nothing in the body is a measurement. Fetch it with the person’s token, as always.
The headers:
Content-Type: application/json
User-Agent: Keely-Webhooks/1.0
Keely-Event-Id: evt_123
Keely-Event-Type: night.created
Keely-Signature: t=1790000000,v1=5f1c…
Verifying the signature
Keely-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256>, where the HMAC is over t, a dot and the raw body, keyed with your subscription’s secret, the one you chose in the portal. Sign the body’s bytes exactly as received, never a re-serialised copy; compare in constant time; and reject a t older than 5 minutes, so a captured delivery can’t be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body as received (a string or Buffer).
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=')),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false;
const expected = createHmac('sha256', secret)
.update(parts.t + '.')
.update(rawBody)
.digest('hex');
return expected.length === parts.v1.length
&& timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
import hashlib, hmac, time
# raw_body: the request body as received, in bytes.
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
if abs(time.time() - int(parts.get("t", 0))) > 300:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256)
return hmac.compare_digest(expected.hexdigest(), parts.get("v1", ""))
Answering, retries and failure
- Answer 2xx within 10 seconds, and do the work afterwards. Queue the event and return; fetch from the API once you’ve answered.
- Retries. Anything else, or no answer in 10 seconds, is retried after 1, 5 and 30 minutes, then 2 and 12 hours; after the sixth attempt the delivery reads failed.
- Failing. A subscription with 20 consecutive failed attempts reads Failing in the portal. Keely keeps trying it; one success sets it back to Active.
- At least once. A timeout after your server took the body is retried, so dedupe by
Keely-Event-Id(or the body’sid). - Order isn’t promised. A retried event can arrive after a newer one;
occurred_atsays which is later, and fetching gives the current state either way. - 30 days. Events and deliveries are kept 30 days, then deleted. Anything older is in
GET /v1/changes.
Subscribing
Subscriptions are made in the portal, at meetkeely.com/developers/apps, under the app:
- The URL,
https://only. - A secret you choose, 32 to 128 characters (the portal can generate one). Keely keeps it to sign deliveries; you keep it to verify them. It isn’t shown again; to rotate it, add a subscription with a new one and delete the old.
- The types you want, or all of them. A new type Keely adds later reaches an all-types subscription without a change.
- A description, up to 120 characters, for you.
Then Send a test: a test.ping goes out within a minute, and the subscription’s recent deliveries show what your server answered. Pause a subscription to stop deliveries without losing it; delete it to end them. An app hears events only while it is approved, or while it is awaiting approval and the person is its developer.