Documentation
Kindryn Public API
Source: docs/API.md
#Kindryn Public API
The Kindryn Public API gives external scripts, internal tooling, and third-party services authenticated read/write access to a community's data. It is the "general purpose" external surface — distinct from the Plugin API, which is scoped to a specific plugin installation.
Both APIs share the same scoped client and permission catalog under the hood, so anything you can do through a plugin you can do through the public API, provided your key has the right scopes.
#Getting an API key
API keys are managed per-community by community admins.
- Sign in to the community.
- Open the sidebar API Keys entry (admin only).
- Click New API key and give it a descriptive name (e.g. "Reporting script", "HubSpot bridge").
- Pick the scopes the key needs. Grant the minimum required — keys cannot be modified to gain new scopes without an explicit edit, but they should be tightly scoped on creation.
- Optionally set an expiration date. Keys with no expiration never expire.
- Click Create API key.
The plaintext token is shown exactly once in a copy-to-clipboard modal. Kindryn never stores the plaintext — only a SHA-256 hash and a 12-character display prefix. If you lose the token, revoke the key and create a new one.
The token format is kak_ (Kindryn API Key) followed by 64 hex characters. The first 12 characters (e.g. kak_abc12345) are the prefix and are safe to display in logs, dashboards, and audit trails.
#Authentication
Pass the token in the Authorization header on every request:
Authorization: Bearer kak_<your-token>
There are no cookies, no sessions, and no CSRF tokens — every request is authenticated independently.
#Errors
| Status | Meaning |
|---|---|
401 | Missing, malformed, or unrecognized API key |
403 | Key is disabled, expired, or missing the required scope |
404 | Resource not found, or unknown route |
405 | Method not allowed for this route (e.g. POST on a read-only route) |
400 | Invalid request body or query parameters |
500 | Internal server error |
All error responses are JSON with a message field:
{ "message": "API key does not have \"posts:write\" scope" }
#Base URL
https://your-kindryn-host/api/public/v1
All endpoints are versioned under /api/public/v1. We commit to backward compatibility within a major version — if a breaking change is required, a new /api/public/v2 namespace will be introduced and the old version will continue to function for a deprecation window.
#Scopes
API keys carry one or more scopes. Each scope grants access to a specific slice of community data. Scopes mirror the plugin permission catalog, so the two systems share one access model.
| Scope | Required by | Description |
|---|---|---|
community:read | GET /community | Read community info (name, branding) |
members:read | GET /members, GET /members/:id, GET /members?email= | Read member list, profiles, resolve by email |
members:write | POST /members, POST /members/ensure | Add/invite a member, or get-or-create by email |
invitations:read | GET /invitations | Read invitation list and status |
invitations:write | POST /invitations, POST /invitations/bulk | Create and manage invitations |
spaces:read | GET /spaces | Read space list + ephemeral lifecycle |
spaces:write | POST/DELETE /spaces/:id/access | Grant/revoke a member's access to a space |
posts:read | GET /posts, GET /posts/:id | Read posts and their comments |
posts:write | POST /posts | Create posts in spaces |
events:read | GET /events, GET /events/:id | Read events and RSVPs |
events:write | POST /events | Create events in spaces |
courses:read | GET /courses | Read course and lesson data |
coaching:read | GET /coaching/sessions | Read coaching sessions |
enrollments:read | GET /enrollments | Read enrollments + sales attribution |
auth:login-link | POST /auth/login-link | Issue a single-use SSO handoff link |
comp-grants:write | POST /comp-grants, POST /comp-grants/:code/redeem | Push/clear comp grants that follow a member |
A request to an endpoint whose scope is not granted returns 403.
#Endpoints
#Community
#GET /community
Returns the community profile (name, slug, description, logo, brand color).
#Example
curl https://your-kindryn-host/api/public/v1/community \
-H "Authorization: Bearer kak_<token>"
{
"id": "ckxxxxx",
"name": "Builders Guild",
"slug": "builders-guild",
"description": "A community for indie hackers.",
"logo": "https://...",
"coverImage": null,
"brandColor": "#e67e22",
"createdAt": "2026-01-15T12:00:00.000Z"
}
#Members
#GET /members
Requires: members:read
Query params: limit (1-100, default 50), offset, role, search.
Returns { data: Member[], total, limit, offset }.
#GET /members/:memberId
Requires: members:read
Returns a single member profile with social links.
#POST /members
Requires: members:write
Add a member directly (if they have a verified Kindryn account) or send them an invitation (if they don't). The role cap of the API key creator is enforced — you cannot mint a role higher than the creator's own role.
Body:
{
"email": "[email protected]",
"role": "MEMBER",
"sendWelcomeEmail": true
}
Possible outcomes (check the outcome field):
| Outcome | Status | Meaning |
|---|---|---|
member_created | 201 | User existed + was verified — added directly as a member |
invitation_sent | 202 | User not found — invitation email sent |
unverified_user_invited | 202 | User exists but unverified — invitation created (no email) |
On member_created the response includes member.id, member.role, etc. On invitation outcomes the response includes invitation.id, invitation.email, etc. The invitation token is never included in the response.
#Example
# List members
curl "https://your-kindryn-host/api/public/v1/members?limit=10&search=jane" \
-H "Authorization: Bearer kak_<token>"
# Add a member
curl -X POST "https://your-kindryn-host/api/public/v1/members" \
-H "Authorization: Bearer kak_<token>" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "role": "MEMBER" }'
#GET /members?email=<email>
Requires: members:read
Resolve a person by email to their Kindryn identity and membership status in this community. Email is matched case-insensitively (lowercased + trimmed).
Always returns 200 — a missing identity is a successful "not found" answer, not a 404 (so partners can poll without treating it as an error).
// Found, and a member of this community:
{
"found": true,
"user": { "id": "ckxxx", "name": "Jane", "email": "[email protected]", "emailVerified": true, "image": null },
"member": { "isMember": true, "id": "ckmmm", "role": "MEMBER", "joinedAt": "2026-01-01T00:00:00.000Z" }
}
// Found, but NOT a member of this community:
{ "found": true, "user": { ... }, "member": { "isMember": false } }
// Not found:
{ "found": false, "email": "[email protected]" }
#POST /members/ensure
Requires: members:write. Idempotent and race-safe.
Resolve or provision a member by email. If the email already maps to a member of this community, returns it unchanged. Otherwise creates a member backed by an unverified, credential-less Kindryn account — so when the person first logs in by email (magic-link recommended), better-auth links them to this account instead of creating a duplicate.
This endpoint never authenticates anyone and never sends email. The provisional account is inert until the real person completes a real login. (forceCreate is admin-only — the API key's creator must be an OWNER/ADMIN of this community, else 403.)
Body:
{ "email": "[email protected]", "name": "Optional Name" }
| Outcome | status | HTTP | Meaning |
|---|---|---|---|
already_member | existing | 200 | Already a member here — returned unchanged |
member_created | existing | 200 | A verified Kindryn user existed — added as member |
member_force_created | provisional | 201 | Provisioned a member + unverified account |
Response: { "userId": "...", "memberId": "...", "status": "...", "outcome": "..." }
#Example
# Resolve an email
curl "https://your-kindryn-host/api/public/v1/[email protected]" \
-H "Authorization: Bearer kak_<token>"
# Get-or-create a member (idempotent)
curl -X POST "https://your-kindryn-host/api/public/v1/members/ensure" \
-H "Authorization: Bearer kak_<token>" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "name": "Holder Name" }'
#Invitations
#GET /invitations
Requires: invitations:read
Query params: status (pending | used | revoked | expired | all, default all), limit (1-200, default 50).
Returns an array of invitation objects with inviteUrl included. The invitation token is never returned.
#POST /invitations
Requires: invitations:write
Create an invitation (always results in an invitation record, even if the target user already has a verified account — use POST /members if you want to direct-add verified users). The invitation token is not returned; use inviteUrl from the response to send to the recipient.
Body:
{
"email": "[email protected]",
"role": "MEMBER",
"name": "Jane Doe",
"sendWelcomeEmail": true
}
All fields except email are optional. role defaults to MEMBER.
Possible outcomes (same as POST /members):
| Outcome | Status | Meaning |
|---|---|---|
member_created | 201 | User was already verified — direct-added as a member |
invitation_sent | 202 | Invitation created and email sent |
unverified_user_invited | 202 | Invitation created; user exists but email unverified |
#POST /invitations/bulk
Requires: invitations:write
Send up to 50 invitations in a single request. Entries are processed concurrently. Per-entry errors do not abort the batch — check each result's outcome field.
Admin UI bulk vs API bulk: the admin UI allows 100 entries per batch; the public API is capped at 50 to limit blast radius from automation.
Body (array):
[
{ "email": "[email protected]", "role": "MEMBER" },
{ "email": "[email protected]", "role": "MODERATOR", "name": "Bob" }
]
Returns:
{
"results": [
{ "index": 0, "email": "[email protected]", "outcome": "invitation_sent", "invitation": { ... } },
{ "index": 1, "email": "[email protected]", "outcome": "member_created", "member": { ... } }
]
}
#Spaces
#GET /spaces
Requires: spaces:read
Query params: limit, offset, type (DISCUSSION, COURSE, EVENT_SERIES, COACHING).
Returns { data: Space[], total, limit, offset }. Each space includes ephemeral (boolean) + ephemeralExpiresAt (ISO string or null) — an ephemeral space auto-archives at that time (see the onSpaceLifecycle webhook in INTEGRATIONS.md).
#POST /spaces/:spaceId/access
Requires: spaces:write. Idempotent.
Grant a member access to a specific space (a MANUAL access grant). Resolve the target by memberId (preferred — the stable id from POST /members/ensure) or email (must already be a member of this community). Optional expiresAt (ISO) + notes.
Body: { "memberId": "ck..." } _or_ { "email": "[email protected]", "expiresAt": null, "notes": null }
| Status | Meaning |
|---|---|
| 201 | New MANUAL grant created (created: true) |
| 200 | Active grant already existed, unchanged (created: false) |
| 400 | Neither memberId nor email; or space not in community |
| 404 | email resolves to no member of this community |
Returns { created, access } (the serialized MemberSpaceAccess row).
#DELETE /spaces/:spaceId/access
Requires: spaces:write. Idempotent.
Revoke a member's MANUAL access to a space. Member identity via body or query (?memberId= / ?email= — some clients drop DELETE bodies). Only soft-revokes MANUAL grants; SUBSCRIPTION/ENROLLMENT access is Kindryn-owned and untouched.
Returns { revoked: <count> } (200, including { revoked: 0 } when nothing was active).
#Auth
#POST /auth/login-link
Requires: auth:login-link.
Issue a single-use, short-lived SSO handoff URL that drops a member straight into the community already authenticated — no password, no second signup. Built for partner integrations (e.g. an event platform) that have already verified the person on their side and provisioned a Kindryn member (see POST /members/ensure).
Body:
{ "email": "[email protected]", "redirectPath": "/<community>/spaces/<space>", "ttlSeconds": 300 }
emailoruserId(one required) — must resolve to a member of this community.redirectPath(optional) — same-origin path to land on after login; defaults to the community home. Open-redirect attempts (absolute URLs,//host) are rejected.ttlSeconds(optional) — clamped to 60–300s (default 300).
Provisional account → 201:
{ "url": "https://<community>/auth/link?token=…", "expiresAt": "2026-06-18T12:34:56.000Z" }
Hand the url to the user; opening it mints a session (the link is single-use and expires) and redirects to redirectPath.
Existing (claimed) account → 409:
{ "reason": "account_exists", "fallbackUrl": "https://<community>/invite/<token>" }
Security — no silent auth into a claimed account. A login-link only auto-mints a session for a _provisional, never-claimed_ member (unverified email and no credential/social login). If the email maps to a real account, the request is refused (
409) — silently logging in there would be an account-takeover vector. Instead an invitation-acceptfallbackUrlis returned: opening it proves email possession before granting access. Tokens are single-use and short-lived (≤5 min).
#Enrollments
#GET /enrollments
Requires: enrollments:read.
List program enrollments with full sales attribution — built for at-event sales reporting and reconciliation/backfill. Each row is the same enriched shape the onEnrollmentCompleted webhook emits.
Query params (all optional): status (PENDING|ACTIVE|COMPLETED|CANCELLED|PAUSED), sourceEventExternalId (the originating event id), saleChannel (self_serve|back_of_room|sales_assisted|comp), from / to (ISO created-at window), limit (1–100, default 50), offset.
Returns { data: Enrollment[], total, limit, offset } where each Enrollment:
{
"communityId": "ckxxxx",
"enrollmentId": "enr_...",
"member": { "memberId": "...", "userId": "...", "email": "[email protected]", "name": "Buyer" },
"plan": {
"id": "...",
"name": "Flagship",
"planType": "FIXED_TERM",
"paymentSchedule": "INSTALLMENTS"
},
"money": { "contractValueCents": 499700, "collectedToDateCents": 99700, "currency": "usd" },
"enrollment": {
"id": "enr_...",
"status": "ACTIVE",
"saleMode": "LIVE_EVENT",
"termStartsAt": "2026-06-01T00:00:00.000Z",
"termEndsAt": "2027-06-01T00:00:00.000Z"
},
"attribution": {
"salesRep": { "memberId": "...", "userId": "...", "name": "Rep Roe" },
"affiliate": { "affiliateId": "...", "referralCode": "kref_abc123" }
},
"eventContext": {
"sourceEventExternalId": "evt_42",
"soldByMemberId": "...",
"saleChannel": "back_of_room"
}
}
attribution.salesRep is the closing rep (soldByMemberId) or, if none, the plan's default sales coach; attribution.affiliate is the depth-0 affiliate commission for the sale. eventContext is null for unattributed sales. collectedToDateCents is a best-effort snapshot of cash collected so far (it grows as installments bill); contractValueCents is the deal size.
#Comp grants
A comp grant is a complimentary ticket (issued in a partner system like Eventicus) that follows the member around Kindryn as a banner until they redeem it. Push grants in and push redemptions back.
#POST /comp-grants
Requires: comp-grants:write. Idempotent (upserts on code).
{
"email": "[email protected]",
"code": "COMP-VIP-42",
"label": "VIP weekend pass",
"redeemUrl": "https://tickets.eventicus.com/redeem/COMP-VIP-42",
"ticketType": "vip",
"sourceEventExternalId": "evt_42",
"expiresAt": "2026-08-01T00:00:00.000Z"
}
emailoruserId(one required) — must resolve to a member of this community (404 if not; ensure the member first).redeemUrlmust behttps://.ticketType,sourceEventExternalId,expiresAtoptional.- Re-pushing the same
coderefreshes the grant and resets it toPENDING.
Returns 201 { created: true, grant } (new) or 200 { created: false, grant } (updated).
#POST /comp-grants/:code/redeem
Requires: comp-grants:write. Idempotent.
Marks the grant REDEEMED so the member's banner clears. Returns { redeemed: <count> } ({ redeemed: 0 } when nothing was pending).
#Posts
#GET /posts
Query params: spaceId (optional — narrow to a single space), limit, offset, sort (newest | oldest).
Returns { data: Post[], total, limit, offset }.
#GET /posts/:postId
Returns a single post with up to 100 comments.
#POST /posts
Body:
{
"spaceId": "ckxxxxx",
"title": "Optional title",
"body": "<p>HTML body (TipTap-rendered)</p>",
"authorId": "user-id-of-the-author"
}
The authorId must be a userId of an existing community member — the post is attributed to that member. This means external systems posting on behalf of a real user need to know the user's ID upfront. (Use GET /members to look it up by email or name.)
Returns the created post with 201 Created.
#Events
#GET /events
Query params: spaceId, limit, offset, upcoming (boolean), status.
#GET /events/:eventId
#POST /events
Body:
{
"spaceId": "ckxxxxx",
"title": "Office Hours",
"description": "Weekly Q&A",
"startsAt": "2026-05-01T17:00:00.000Z",
"endsAt": "2026-05-01T18:00:00.000Z",
"capacity": 50,
"isVirtual": true,
"meetingUrl": "https://meet.example.com/abc"
}
Events created via the API start in DRAFT status and must be published through the admin UI before members see them.
#Courses
#GET /courses
Query params: spaceId, limit, offset, published (boolean).
#Coaching
#GET /coaching/sessions
Query params: spaceId, limit, offset, upcoming (boolean).
#Code examples
#curl
# List members
curl "https://your-kindryn-host/api/public/v1/members?limit=20" \
-H "Authorization: Bearer kak_<token>"
# Add a member (direct-add or invitation depending on account status)
curl -X POST "https://your-kindryn-host/api/public/v1/members" \
-H "Authorization: Bearer kak_<token>" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "role": "MEMBER" }'
# List pending invitations
curl "https://your-kindryn-host/api/public/v1/invitations?status=pending" \
-H "Authorization: Bearer kak_<token>"
# Bulk invite
curl -X POST "https://your-kindryn-host/api/public/v1/invitations/bulk" \
-H "Authorization: Bearer kak_<token>" \
-H "Content-Type: application/json" \
-d '[{"email":"[email protected]"},{"email":"[email protected]","role":"MODERATOR"}]'
# Create a post
curl -X POST "https://your-kindryn-host/api/public/v1/posts" \
-H "Authorization: Bearer kak_<token>" \
-H "Content-Type: application/json" \
-d '{
"spaceId": "ckxxxxx",
"title": "Hello from a script",
"body": "<p>Posted via the public API.</p>",
"authorId": "user-id-of-the-poster"
}'
#JavaScript / Node.js (fetch)
const KINDRYN = 'https://your-kindryn-host/api/public/v1'
const API_KEY = process.env.KINDRYN_API_KEY
async function listMembers() {
const res = await fetch(`${KINDRYN}/members?limit=50`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
if (!res.ok) {
const err = await res.json()
throw new Error(`Kindryn API error ${res.status}: ${err.message}`)
}
return res.json()
}
async function addMember(email, role = 'MEMBER') {
const res = await fetch(`${KINDRYN}/members`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, role }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(`Kindryn API error ${res.status}: ${err.message}`)
}
return res.json() // { outcome, member? } or { outcome, invitation? }
}
async function bulkInvite(entries) {
// entries: [{ email, role?, name? }, ...] — max 50
const res = await fetch(`${KINDRYN}/invitations/bulk`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(entries),
})
if (!res.ok) {
const err = await res.json()
throw new Error(`Kindryn API error ${res.status}: ${err.message}`)
}
return res.json() // { results: [{ index, email, outcome, ... }] }
}
async function createPost(spaceId, authorId, body) {
const res = await fetch(`${KINDRYN}/posts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ spaceId, authorId, body }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(`Kindryn API error ${res.status}: ${err.message}`)
}
return res.json()
}
#Python (requests)
import os
import requests
KINDRYN = "https://your-kindryn-host/api/public/v1"
API_KEY = os.environ["KINDRYN_API_KEY"]
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {API_KEY}"})
def list_members(limit=50):
r = session.get(f"{KINDRYN}/members", params={"limit": limit})
r.raise_for_status()
return r.json()
def add_member(email, role="MEMBER"):
r = session.post(f"{KINDRYN}/members", json={"email": email, "role": role})
r.raise_for_status()
return r.json() # { "outcome": "member_created"|"invitation_sent"|..., ... }
def bulk_invite(entries):
# entries: list of { "email": ..., "role"?: ..., "name"?: ... } — max 50
r = session.post(f"{KINDRYN}/invitations/bulk", json=entries)
r.raise_for_status()
return r.json() # { "results": [...] }
def create_post(space_id, author_id, body, title=None):
payload = {"spaceId": space_id, "authorId": author_id, "body": body}
if title:
payload["title"] = title
r = session.post(f"{KINDRYN}/posts", json=payload)
r.raise_for_status()
return r.json()
#Rate limiting
Kindryn does not currently enforce per-key rate limits, but the database and process limits still apply. Treat the API as best-effort at this stage:
- Stay below ~10 requests/second for steady-state load.
- Use bulk-friendly query params (
limit=100) instead of one-request-per-row. - Add jitter to your retry loop and exponential backoff on
5xx.
A future release will add per-key rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset). Until then, design your integrations defensively.
#Security notes
- Treat API keys like passwords. Never commit them to git, never paste them into client-side JavaScript, never log the full token. The 12-character prefix is safe to log; the rest is not.
- Scope keys narrowly. A reporting script doesn't need
posts:write. Granting only what's needed limits blast radius if a key leaks. - Set expirations on temporary keys. If a vendor needs short-term access, give them a key that expires in 30 days.
- Rotate after employee turnover. Revoke keys created by team members who have left.
- Revoke immediately on suspected leak. Revocation takes effect on the next request — there is no propagation delay.
#Comparison to the Plugin API
| Feature | Public API (/api/public/v1) | Plugin API (/api/plugins/api) |
|---|---|---|
| Token prefix | kak_ | kpk_ |
| Created by | Community admins | Plugin install flow |
| Scoped to | A community | A specific plugin installation |
| Permission model | API key scopes | Granted plugin permissions |
| URL style | RESTful (GET /members) | RPC (POST { method, params }) |
| Storage / settings access | No | Yes (per-installation scope) |
| UI injection / hook receiver | No | Yes |
If you're building a fully-fledged extension that needs storage, settings, and UI injection, build a plugin. If you're connecting an external system or running a standalone script, use the public API with an API key.
