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), offset (default 0).

limit is capped at 200, so page with offset for anything larger — request until a response comes back with fewer rows than limit:

curl -H "Authorization: Bearer kak_<token>" \
  "$BASE/invitations?status=all&limit=200&offset=0"
curl -H "Authorization: Bearer kak_<token>" \
  "$BASE/invitations?status=all&limit=200&offset=200"

Results are ordered createdAt descending, so paging is stable across calls.

Fixed 2026-08-10. status=pending and status=expired previously returned 500 on every call: both filters treated the required Invitation.expiresAt column as nullable, which Prisma rejects. used, revoked and all were unaffected. If you worked around it by using status=all, you can now use the narrower statuses.

Returns an array of invitation objects with inviteUrl included. The invitation token is never returned.

Use this for async reconciliation — answering "did they accept?" without waiting on a fire-and-forget webhook:

{
  "id": "inv_…",
  "email": "[email protected]",
  "name": "Invitee",
  "role": "MEMBER",
  "invitedBy": "mem_…",
  "inviteUrl": "https://community.example.com/invite/<token>",
  "usedAt": "2026-08-05T10:00:00.000Z", // non-null = ACCEPTED
  "revokedAt": null,
  "expiresAt": "2026-09-01T00:00:00.000Z",
  "createdAt": "2026-08-01T00:00:00.000Z",
}

Two things worth knowing about inviteUrl:

  • It is built from the community's own host — its verified custom domain when it has one, otherwise the platform host — so it matches the link the invitee was emailed. (Before WHP-628 it always used the platform host, which diverged the moment a community verified a custom domain.)
  • It deliberately omits the ?iv= nonce present in the emailed link. That nonce skips email re-verification and belongs only in mail sent to that address, never in a copyable link.

#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,
  "grantSpaceIds": ["spc_abc", "spc_def"]
}

All fields except email are optional. role defaults to MEMBER.

betaTester: true stamps the person as a beta tester (WHP-274) — the same flag /admin/beta sets by hand. Both branches are covered: someone who already has an account is stamped immediately, and an invitee gets the flag when they accept, so it is correct whether or not they exist yet. The check is strict === true, because the string "false" — what a form-encoded or spreadsheet-driven integration sends — is truthy and would otherwise mark an entire imported cohort. Marking is one-way here: passing false never removes an existing tester's flag.

Combined with isBetaTester in the members query, this is what lets a Segment target the beta cohort and stay current — add a tester through the API and they join the segment's audience automatically, including any gathering scoped to it.

grantSpaceIds grants those spaces as part of the invite, so importing a cohort is one call rather than one call per member per space. A single id may be sent as a bare string ("grantSpaceIds": "spc_abc") — integration builders routinely do, and failing an import over that would be a formatting detail.

⚠️ Granting is a diff, not a set. Spaces the member already has are skipped, so onSpaceAccessGranted and space onboarding fire only on genuinely new access. Re-running an import does not re-assign onboarding workflows, and this endpoint never revokes — sending a shorter list does not remove anything.

⚠️ An existing member returns 200 already_member, not an error. The missing spaces are still granted first. This is what makes an import safely repeatable: a person belonging to two cohorts, or a re-run after a partial failure, both succeed instead of erroring while having silently done the work. (POST /members still returns 409 for an existing member — different intent: "add this person" cannot be satisfied twice, whereas "make sure they have these spaces" can.)

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 sequentially — 50 concurrent invitation writes would spike the database and the email provider's rate limits without meaningful throughput gain. 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": { ... } }
  ]
}
#Granting spaces to a whole batch

betaTester follows the same per-entry / batch-level rule as grantSpaceIds below — set it once for the whole import, override it on individual entries.

grantSpaceIds may be set per entry, or once at the batch level to apply to every entry — a cohort import sends one list and one set of spaces, and repeating the ids on all 50 entries is noise. A per-entry value wins.

⚠️ The batch-level form requires the object body with an entries key. The bare-array body above has nowhere to carry a batch-level value.

{
  "entries": [{ "email": "[email protected]" }, { "email": "[email protected]" }],
  "grantSpaceIds": ["spc_abc"]
}

Inviting someone who is already a member is not an error: the call returns outcome: "already_member" with grantedSpaceIds listing what was added, and re-sending the same request is a no-op rather than a duplicate grant. That makes a cohort list safe to replay after a partial failure.

Inviting someone who already has an invitation in flight is likewise not an error and no longer duplicates anything. The call returns outcome: "invitation_already_pending" with the existing invitation, sends no email, and merges any new grantSpaceIds or betaTester into the invite that's already out there.

⚠️ It is a distinct outcome rather than a quiet invitation_sent because that would report "invited" for someone who received nothing. If you actually want another email to go out, that is POST /invitations/{id}/resend — a deliberate act, not a side effect of replaying an import.

Only _live_ invitations match. An accepted, revoked, or expired one does not, so re-inviting someone whose invite lapsed mints a fresh one instead of handing back a dead link.

#Spaces

#POST /space-access/bulk

Requires: spaces:write

Grant space access to up to 50 members in one request. POST /spaces/:id/access is one member per call, so importing 275 people into 2 spaces was 550 requests — fast enough to queue behind the connection pool, and too slow to pace from a Google Apps Script without hitting its 6-minute execution limit. The loop belongs here, paced once and correctly.

Entries are processed sequentially. Grants send no email and fire no notification, so the only limit is the database.

{
  "spaceIds": ["the-space-slug", "spc_abc"],
  "expiresAt": "2026-12-01T00:00:00.000Z",
  "notes": "Beta cohort 2",
  "entries": [
    { "email": "[email protected]" },
    { "memberId": "mem_123", "expiresAt": "2027-01-01T00:00:00.000Z" }
  ]
}

Returns per-entry results (granted / already_had / error) plus a summary. A failing entry never aborts the batch — one bad address in row 40 must not discard the 39 grants before it.

Safe to replay. Grants are idempotent, so re-running a sheet after a partial failure is a no-op for everyone already done.

⚠️ spaceIds accepts an id or a slug, and an unknown one is a 400 naming it, with nothing written. Previously a slug or typo matched no rows and the call still returned success having granted nothing — invisible until a member said they couldn't see the space. Rejecting the whole batch up front means a failed request is always safe to retry.

expiresAt may be set for the batch and overridden per entry; absent means the grant does not expire. An unparseable batch-level date is a 400 before anything is written; an unparseable per-entry date fails only that row. It is never treated as "no expiry" — a typo silently producing permanent access is the failure nobody notices.

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