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/:idRead member list and individual profiles
members:writePOST /membersAdd a member directly or via invitation
invitations:readGET /invitationsRead invitation list and status
invitations:writePOST /invitations, POST /invitations/bulkCreate and manage invitations
spaces:readGET /spacesRead space list and metadata
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

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" }'

#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

Query params: limit, offset, type (DISCUSSION, COURSE, EVENT_SERIES, COACHING).

Returns { data: Space[], total, limit, offset }.

#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.