Documentation
Read the graph
Every fact Kicklace holds about your customers — the people, the organizations, what they pay, what they did, your deployments, where everybody stands on the lifecycle, and the populations Kicklace works out on its own — answered three ways: REST addresses under /api/v1/, MCP resources, and the kicklace package's own reads. All three run through one reader, so the same question gets the same answer however you ask it.
Your customer data is an API an agent can operate, not a chat window bolted onto a database.
Every fact Kicklace holds about your customers — the people, the organizations they belong to,
what they pay, what they did, the deployments you have shipped, where everybody stands on the
lifecycle, and the populations Kicklace works out on its own — is one reader, answered three
ways: a REST address under /api/v1/, an MCP resource, and the kicklace package. All three run
through the same code, so a person read through one is the same JSON a person read through
another.
This is reads only. Writing — an event, a note, a subscription to a list — is the events API and the MCP server; this page is what comes back.
Authorization
Every address here takes the same two kinds of bearer token the events API does:
Authorization: Bearer sk_live_… a secret key, pasted under Connections → Your website
Authorization: Bearer kla_… an access token from Claude's own sign-in
A key stays one workspace's. A connection that reached "all your workspaces" through Claude's own
sign-in reads whichever workspace you name with ?workspace=<slug or name> — left out, the one it
last acted in, or its only one. A key made under Connections → Your website → For your
developers for a customer's own backend may read; the setup token npx kicklace init --setup
exchanges cannot — it lasts fifteen minutes and is spent by the one exchange it is for.
A request with no credential, or one Kicklace does not know, is refused in one sentence:
{ "ok": false, "error": "invalid_key", "message": "…" }
The addresses
| Address | Query | Answers |
|---|---|---|
GET /api/v1/people |
q, stage, population, organization, updated_since, after, limit |
a page of people |
GET /api/v1/people/{id} |
— | one person |
GET /api/v1/organizations |
q, updated_since, after, limit |
a page of organizations |
GET /api/v1/organizations/{id} |
— | one organization |
GET /api/v1/subscriptions |
after, limit |
a page of customer_subscriptions rows, each with its payer |
GET /api/v1/events |
person, name, since, after, limit |
a page of what people did |
GET /api/v1/releases |
after, limit |
a page of deployments, newest first |
GET /api/v1/releases/{sha} |
— | one deployment, by its sha or the version string it was tagged with |
GET /api/v1/journey |
— | the seven lifecycle rungs, and how many are stuck |
GET /api/v1/populations |
— | every population, with its count and its formula |
A list answers { "data": [ … ], "next": "…" }, where next is a cursor for the next page or
null at the end — pass it back as after. limit is 1 to 100, 50 when left out. There is no
total: read until next is null. A one-record address answers that record's own shape
directly, with no envelope around it. Cache-Control: no-store on every answer, because what it
says is true this second and nothing here is meant to be cached.
People
{
"id": "rec_7f3a2c9e",
"name": "Ada Lovelace",
"email": "ada@example.com",
"identities": [{ "kind": "email", "value": "ada@example.com" }],
"stage": { "key": "paying", "name": "Paying" },
"state": "paying",
"organization": { "id": "rec_org_1", "name": "Analytical Engines" },
"money": { "monthlyRecurring": 4900, "creditsRunRate": 0, "currency": "usd", "otherCurrencies": [] },
"reading": "Paying for three months and slowing: 2 events in 14 days against 9 the 14 before.",
"createdAt": "2026-01-04T09:12:00.000Z",
"updatedAt": "2026-09-10T16:40:00.000Z",
"url": "https://www.kicklace.com/app/north-shelf/directory/people/rec_7f3a2c9e"
}
money is Kicklace's own rule, kept here rather than added away: monthlyRecurring is a
subscription that renews by itself, creditsRunRate is one-off purchases of the last ninety days
averaged to a month, and the two are never printed as one figure — a screen that added them would
be saying something neither half says on its own. Where somebody pays nothing themselves and their
organization does, state and money are the organization's and via names it. reading is the
same sentence a person reads over their name in the app — one line of interpretation, never a
column read out loud — and is absent where Kicklace has nothing to say yet.
?population=<key> narrows to a population's members, ?stage=<key or name> to one lifecycle
rung, ?organization=<id or name> to one organization's people, ?updated_since=<ISO 8601> to
what changed since a moment. GET /api/v1/populations names every population key there is.
Organizations
The same shape, without stage or via, plus archived. An organization is a company or a team
your people belong to — never one of a single person — made only by something real: a Clerk or
Kinde organization, a GitHub sponsoring organization, or a Stripe customer somebody has said is an
organization's.
Subscriptions
{
"id": "sub_9k2m",
"payer": { "id": "rec_org_1", "name": "Analytical Engines", "kind": "organization" },
"provider": "stripe",
"providerId": "sub_1Q2w3e",
"status": "active",
"plan": "Studio",
"priceAmount": 4900,
"currency": "usd",
"interval": "month",
"intervalCount": 1,
"quantity": 3,
"monthlyAmount": 14700,
"currentPeriodStart": "2026-09-01T00:00:00.000Z",
"currentPeriodEnd": "2026-10-01T00:00:00.000Z",
"cancelAtPeriodEnd": false,
"createdAt": "2026-03-01T00:00:00.000Z",
"updatedAt": "2026-09-01T00:00:00.000Z"
}
payer is whoever the money sits on: a person, or an organization. monthlyAmount is the price
already normalised to a month, in the price's own minor units — a year's price ÷ 12, a week's × 52
÷ 12 — so summing a page is always a plain sum.
Events
{
"id": "int_4h7q",
"name": "purchase",
"person": { "id": "rec_7f3a2c9e", "name": "Ada Lovelace" },
"at": "2026-09-10T16:40:00.000Z",
"properties": { "value": 49 }
}
What a person did: a built-in name (purchase, signup, download, activated, and the rest
Events names) or one of the workspace's own, the same names kl.track() posts.
?person=<id> takes a record id from GET /api/v1/people — not an address; narrow by address
with ?q= on the people list first. ?name= and ?since= narrow further, and paging is the same
cursor every list uses.
Releases
{
"id": "rec_rel_9",
"name": "Ship the graph API",
"sha": "a1b2c3d",
"at": "2026-09-16T09:00:00.000Z",
"changes": {
"repo": "kicklace/kicklace",
"base": "e5f6a7b",
"head": "a1b2c3d",
"totalCommits": 4,
"totalFiles": 12
},
"url": "https://www.kicklace.com/app/north-shelf/directory/releases/rec_rel_9"
}
changes is GitHub's own compare of this deployment against the one before it, read once and kept
— absent where no compare has been read yet, or where nothing can read your repository.
GET /api/v1/releases/{sha} takes the sha Vercel deployed or the version string your own server
sent as release on an event, whichever names one.
Journey
{
"stages": [
{ "key": "signed_up", "name": "Signed up", "count": 214, "medianDays": 3.5, "stuck": 12, "arrived": 18, "wentFurther": 9 }
],
"total": 940
}
One row per lifecycle rung, in order: how many stand on it, how long half of them have been there, how many arrived in the last month and how many of those went further, and how many are stuck — on the rung past its own usual wait, with nothing recorded since.
Populations
{ "data": [{ "key": "group:quiet", "name": "Quiet", "count": 6, "formula": "Nothing recorded for 30 days." }] }
A population is a set of people who satisfy one meaningful condition right now — a lifecycle rung,
one of Kicklace's own groups (Likely to leave, Payment issue, Quiet, Slowing, High-value, Stuck on
a rung), or a saved view. Kicklace works out who is in each; nothing here proposes a new one, and
formula is the rule as the sentence a person reads on the Populations page. Membership is
GET /api/v1/people?population=<key>, never a field on this list.
The MCP resources
The same ten reads, offered the way the MCP server expects data to be found rather
than asked for: resources/list names the seven whole ones, resources/templates/list the three
that take an id or a sha, and resources/read answers with application/json — the exact body a
REST call to the matching address would answer.
kicklace://people kicklace://people/{id}
kicklace://organizations kicklace://organizations/{id}
kicklace://subscriptions
kicklace://events
kicklace://releases kicklace://releases/{sha}
kicklace://journey
kicklace://populations
Every filter a REST address takes is a query parameter on the same uri —
kicklace://people?stage=paying&limit=10 — workspace included, the same argument a tool call
takes for a connection that reaches more than one.
The package's reads
kicklace.people, .organizations, .subscriptions, .events and .releases read; .journey()
and .populations() have no page to be a namespace over, so they are plain methods:
const paying = await kl.people.list({ stage: "paying", limit: 20 });
if (paying.ok) for (const person of paying.data) console.log(person.name, person.money);
for await (const person of kl.people.iterate({ population: "group:likely-to-leave" })) {
// every page, read on its own
}
list() answers one page and never throws, the same rule every write in the package keeps;
iterate() is the same read as an async generator, and a page that comes back refused simply ends
the loop rather than throwing into yours. See the package's own reference for every
method.
Limits
| What | Limit |
|---|---|
| Rows in one page | 1 to 100, 50 when limit is left out |
| Calls in ten minutes | 3,000 a key or a signed-in connection — a separate, more generous count from the 600 calls a key gets on /api/mcp or on /api/v1/events, because paging through a workspace is ordinary here |
Past the limit, every address answers the same way the rest of /api/v1/ does:
{ "ok": false, "error": "rate_limited", "message": "…" }
Errors
Every refusal is one sentence with the fix inside it, in the same shape as the rest of
/api/v1/: { "ok": false, "error": "…", "message": "…" }.
error |
When |
|---|---|
invalid_key |
No credential, or one Kicklace does not know — a setup token included, which cannot read |
invalid_token |
An access token that has expired, been disconnected, or was issued for another server |
no_longer_a_member |
The member behind the key or the connection has left the workspace |
unknown_workspace / say_which_workspace |
A connection that reaches several workspaces was not told which, or named one it does not reach |
not_found |
No record, release or resource matches the id, the sha or the uri |
unknown_stage / unknown_population / unknown_organization / ambiguous_organization |
A filter named something this workspace does not have, or matched more than one organization |
bad_argument |
updated_since or since was not ISO 8601 |
rate_limited |
Too many reads in ten minutes |