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 | Read member list and individual profiles |
members:write | POST /members | Add a member directly or via invitation |
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 and metadata |
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 |
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" }'
#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
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
| 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.
