Documentation

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
  1. The event types
  2. Timing
  3. The digest
  4. The webhook
  5. Verifying the signature
  6. Answering, retries and failure
  7. Subscribing

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.

TypeWhen it firesdataScope
night.createdThe Keely app first backs a night up. It may still be corrected afterwards{ date, recovery_status }nights
night.updatedA night was corrected: a later sync, a settled start, an edit. Only when something but its updated_at changed{ date, recovery_status }nights
night.deletedA night is gone{ date }nights
day.createdThe Keely app first backs a day up{ date }days
day.updatedA day was corrected{ date }days
day.deletedA day is gone{ date }days
workout.createdA workout first backed up, or one the person restored{ id, activity, started_at }workouts
workout.updatedA workout was corrected: its times, name, Load or zones{ id, activity, started_at }workouts
workout.removedThe person removed it{ id, activity, started_at }; { id } alone once the row itself is goneworkouts
journal.entry.createdAn entry was added, by the person, by Keely, by an assistant or by an app{ id, tag, source, starts_at }journal
journal.entry.updatedAn entry changed{ id, tag, source, starts_at }journal
journal.entry.deletedAn entry was deleted{ id, tag }journal
journal.sharing_startedThe person turned journal sharing on{}journal
journal.sharing_endedThey turned it off. The entries’ going with it is silent{}journal
connection.grantedThe person connected your app{ scopes }none: your app only
connection.changedThey changed what it may read{ scopes, before }none: your app only
connection.revokedThey disconnected it, or deleted their Keely account{ reason }: disconnected or account_deletednone: your app only
person.history_changedThe digest: more than 25 events for one person were waiting at once{ since, events }none: your app only
test.pingYou 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_* or connection.* 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_at and data. A correction to something whose created hasn’t gone out yet folds into the created. 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.created as “a morning has landed” and night.updated as “read it again”.
  • A re-upload of the same numbers is silent. An updated event is written only when something but updated_at changed, 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:

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"
}
  • id is the event’s, stable across retries; person_id the same id GET /v1/me gives; occurred_at when the change happened (for a folded correction, the latest).
  • links points at what to fetch: day for a night or a day, workout for a workout, journal for the journal’s entries, changes for a digest; empty for connection.* and test.ping.
  • Nothing in the body is a measurement. Fetch it with the person’s token, as always.

The headers:

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.

Node
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));
}
Python
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’s id).
  • Order isn’t promised. A retried event can arrive after a newer one; occurred_at says 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.