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/:id, GET /events/:id/rsvp-form, GET /events/:id/linkableRead events, RSVPs, the RSVP form schema, and whether attendance can be recorded
events:writePOST /eventsCreate events in spaces
events:rsvpPOST /events/:id/rsvp, POST /events/:id/attendanceRSVP / record attendance on behalf of a member (both accept ordering fields)
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.

events:rsvp is not events:write. events:write publishes the agenda; events:rsvp decides who is on the list. They are separate on purpose — a key that creates events has no business marking a named member as attending — and events:rsvp is the only scope that lets an external system act _as_ someone else. Grant it only to an integration you trust with that.

⚠️ A plugin manifest may request events:rsvp too. If third-party plugin submission ever opens up, this is the one permission that should need manual approval rather than riding along with the rest of a manifest — today "plugin" is a way of organizing our own code and nobody outside can submit one, so gating it further now would guard a threat that does not exist.

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

#GET /events/:eventId/rsvp-form

Scope: events:read. Read the event's configured RSVP form so an external system can render it.

{
  "eventId": "ckxxxxx",
  "hasForm": true,
  "form": {
    "id": "ckformxxxxx",
    "versionId": "ckverxxxxx",
    "version": 3,
    "createdAt": "2026-01-02T00:00:00.000Z",
    "fields": [
      {
        "id": "ckfieldxxxxx",
        "type": "SINGLE_CHOICE",
        "label": "Which session?",
        "required": true,
        "order": 0,
        "config": { "choices": [{ "value": "am", "label": "Morning" }] },
        "bonusDayId": null
      }
    ]
  }
}

An event with no form (or a form with no published version) returns { "eventId": ..., "hasForm": false, "form": null }. An event outside the key's community returns 404.

versionId is the current version. Form responses are version-stamped, so compare it against the version you rendered from to notice a republished form. Kindryn stamps a submitted response with whatever version is current at submit time — versionId is not a token you send back.

Fields tagged with a bonusDayId are returned unfiltered; bonus-day eligibility is per member, and the write path filters on submit.

#POST /events/:eventId/rsvp

Scope: events:rsvp. Write an RSVP on behalf of a member.

{
  "memberId": "ckmemberxxxxx",
  "email": "[email protected]",
  "status": "GOING",
  "answers": { "ckfieldxxxxx": "am" },
  "externalId": "evt_ext_123",
  "seq": 4180,
  "occurredAt": "2026-04-28T09:12:00.000Z",
  "sourceTicketId": "tkt_ext_991"
}
FieldNotes
memberId \emailAt least one. memberId wins; email is a fallback, not an alternative
statusGOING \MAYBE \NOT_GOING. Absent/null/"" un-RSVPs
answersRequired when the event has an RSVP form and status is GOING / MAYBE
externalId / sourceEventExternalIdYour own event id, recorded as provenance on create only
seq / occurredAt / sourceTicketIdOptional ordering — see Ordering under attendance. A superseded push answers 200 with { outcome: "stale_ignored", action: "ignored" } and writes nothing

This is a real RSVP, not a bookkeeping entry: the member receives their calendar invite and reminders, capacity coercion to WAITLISTED applies, and the onEventRsvp plugin hook fires — exactly as if they had clicked _Going_ themselves. Returns the same body the in-app path returns ({ action, rsvp, calendarInviteSent, waitlisted }), except when the push is refused as out of order, which returns the stale_ignored shape above.

Errors: 400 (neither identifier, invalid status, form validation, or the event has already ended), 403 (missing scope or event access), 404 (no such event, or the person is not a member of this community — call POST /members/ensure first).

#POST /events/:eventId/attendance

Scope: events:rsvp. Record — or reverse — who actually turned up.

{
  "memberId": "ckmemberxxxxx",
  "email": "[email protected]",
  "attended": true,
  "attendedAt": "2026-05-01T17:04:00.000Z",
  "sourceEventExternalId": "evt_ext_123",
  "seq": 4182,
  "occurredAt": "2026-05-01T17:04:00.000Z",
  "sourceTicketId": "tkt_ext_991"
}

attended is required and must be a real boolean. Omit attendedAt entirely when attended is false.

Idempotent on (event, member). There is no idempotency key; re-pushing the same attendance is a no-op. attended: false is likewise idempotent, but it only deletes check-ins on RSVPs this integration created (source: "eventicus"). If the member RSVP'd in Kindryn and staff checked them in at the door, the push answers reversal_refused and leaves the check-in alone — an external system does not get to undo a fact Kindryn established itself. Deletion is hard (check-ins carry no soft-delete column).

A member with no RSVP gets one created as GOING — a check-in requires an RSVP — without side effects: no calendar invite, no reminders, no onEventRsvp hook. Mailing an invite for an event someone already attended, and webhooking the attendance back to the system that pushed it, are both failures this prevents.

#Ordering: seq, occurredAt, sourceTicketId

All three are optional. Send none and you get the behavior described above, unchanged — which is what a pre-existing integration does today.

Send them and Kindryn keeps a watermark per seat and refuses anything at or behind it, answering stale_ignored. This matters most on a reversal: an attended: false push carries no attendedAt, so without seq there is nothing to compare, and a retried stale reversal deletes a check-in that a newer attended: true created moments earlier. Check-ins have no soft-delete column, so that loss leaves no trace.

FieldWhat it is
seqYour monotonic counter for this push. A number, or a numeric string for values past 2^53
occurredAtWhen the fact happened on your side. Only consulted when no seq is available on both sides
sourceTicketIdYour id for the seat. Absent, the member is used — so two members on one event never share a watermark

Notes worth knowing before you wire this up:

  • seq beats occurredAt. Clocks skew between two systems; counters do not.
  • A push at exactly the stored seq is refused, not re-applied — it is a retry of work that already landed.
  • RSVP and attendance hold separate watermarks, so a per-stream counter on your side works as well as one global one.
  • Unusable metadata never costs you the push. A malformed seq or an unparseable occurredAt is dropped and the push is accepted, rather than spending one of your retries on a 400.
  • A push answered rsvp_blocked stays re-pushable at its original seq once you grant the member access — that outcome never consumes a position. Every other outcome does, including reversal_refused and a reversal with nothing to reverse: those are final answers for the seat, and re-admitting an older push after one of them is how the two systems end up disagreeing.

Soft conditions answer 200, not an error, and name themselves in outcome. Integrations retry on non-2xx, and none of these would ever succeed on a retry:

outcomeMeaning
recordedCheck-in created
already_recordedCheck-in already existed; left in place
reversedCheck-in deleted
not_recordedNothing to reverse (already in the requested state)
reversal_refusedCheck-in exists on an RSVP Kindryn created itself (not source: "eventicus") — left in place
event_not_foundNo such event in this community
member_not_foundNo member with that memberId/email — ensure them, re-push
rsvp_blockedThe event refuses an RSVP for that member (paid/gated, or a required form) — attendance not recorded
stale_ignoredA newer push for this seat was already applied; this one arrived out of order and was ignored
{
  "outcome": "recorded",
  "recorded": true,
  "eventId": "ckxxxxx",
  "memberId": "ckmemberxxxxx",
  "attended": true,
  "rsvpId": "ckrsvpxxxxx",
  "rsvpCreated": true,
  "checkinId": "ckcheckinxxxxx",
  "checkedInAt": "2026-05-01T17:04:00.000Z",
  "message": "Check-in recorded."
}

Only genuine faults are non-2xx: 401/403 for auth and scope, and 400 for a body missing attended or carrying neither memberId nor email.

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

#GET /events/:eventId/linkable

Scope: events:read

Ask before connecting an external event to this Kindryn one, not after.

{
  "eventId": "evt_abc123",
  "title": "Legends Retreat — Day 1",
  "linkable": false,
  "reasons": ["access is granted by a plan"],
  "message": "Attendance pushed for a member without access may not be recordable: access is granted by a plan. Link a free, ungated fulfillment event instead."
}

linkable: false does not mean the link is forbidden — it means an attendance push for a member who has no Kindryn entitlement to this event cannot be recorded. Kindryn has to create an RSVP to attach a check-in to, that creation runs the entitlement check, and an RSVP also grants access to the event's video call, so it cannot be written past the gate. Without this call the failure surfaces at the door, hours later, as a log line.

⚠️ Price is not the discriminator — entitlement is. A Kindryn event is a fulfillment event: it exists because someone enrolled in something, so it is routinely gated to that program even when it is free. Three of the four reasons apply to free events:

reasonapplies to a free event?
the event has a priceno
access is granted by a planyes
the event is restricted to a segmentyes
the event has a required RSVP formyes

Advisory, not enforcement. Kindryn cannot police what another system links to, and refusing the push later is the behavior that loses data.

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