All docs

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.

  1. Sign in to the community.
  2. Open the sidebar API Keys entry (admin only).
  3. Click New API key and give it a descriptive name (e.g. "Reporting script", "HubSpot bridge").
  4. 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.
  5. Optionally set an expiration date. Keys with no expiration never expire.
  6. 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

StatusMeaning
401Missing, malformed, or unrecognized API key
403Key is disabled, expired, or missing the required scope
404Resource not found, or unknown route
405Method not allowed for this route (e.g. POST on a read-only route)
400Invalid request body or query parameters
500Internal 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.

ScopeRequired byDescription
community:readGET /communityRead community info (name, branding)
members:readGET /members, GET /members/:id, GET /members?email=Read member list, profiles, resolve by email
members:writePOST /members, POST /members/ensureAdd/invite a member, or get-or-create by email
invitations:readGET /invitationsRead invitation list and status
invitations:writePOST /invitations, POST /invitations/bulkCreate and manage invitations
spaces:readGET /spacesRead space list + ephemeral lifecycle
spaces:writePOST/DELETE /spaces/:id/accessGrant/revoke a member's access to a space
posts:readGET /posts, GET /posts/:idRead posts and their comments
posts:writePOST /postsCreate posts in spaces
events:readGET /events, GET /events/:idRead events and RSVPs
events:writePOST /eventsCreate events in spaces
courses:readGET /coursesRead course and lesson data
coaching:readGET /coaching/sessionsRead coaching sessions
enrollments:readGET /enrollmentsRead enrollments + sales attribution
auth:login-linkPOST /auth/login-linkIssue a single-use SSO handoff link
comp-grants:writePOST /comp-grants, POST /comp-grants/:code/redeemPush/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):

OutcomeStatusMeaning
member_created201User existed + was verified — added directly as a member
invitation_sent202User not found — invitation email sent
unverified_user_invited202User 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" }
OutcomestatusHTTPMeaning
already_memberexisting200Already a member here — returned unchanged
member_createdexisting200A verified Kindryn user existed — added as member
member_force_createdprovisional201Provisioned 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):

OutcomeStatusMeaning
member_created201User was already verified — direct-added as a member
invitation_sent202Invitation created and email sent
unverified_user_invited202Invitation 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 }

StatusMeaning
201New MANUAL grant created (created: true)
200Active grant already existed, unchanged (created: false)
400Neither memberId nor email; or space not in community
404email 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 }
  • email or userId (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-accept fallbackUrl is 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"
}
  • email or userId (one required) — must resolve to a member of this community (404 if not; ensure the member first).
  • redeemUrl must be https://. ticketType, sourceEventExternalId, expiresAt optional.
  • Re-pushing the same code refreshes the grant and resets it to PENDING.

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

FeaturePublic API (/api/public/v1)Plugin API (/api/plugins/api)
Token prefixkak_kpk_
Created byCommunity adminsPlugin install flow
Scoped toA communityA specific plugin installation
Permission modelAPI key scopesGranted plugin permissions
URL styleRESTful (GET /members)RPC (POST { method, params })
Storage / settings accessNoYes (per-installation scope)
UI injection / hook receiverNoYes

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.

Kindryn — documentation
© 2026 Capacity in Reserve LLC. All rights reserved.