All docs

Documentation

Integrations — Zapier, Make, and any webhook consumer

Source: docs/INTEGRATIONS.md

#Integrations — Zapier, Make, and any webhook consumer

Kindryn's webhook system is vendor-agnostic: any service that can accept a signed HTTP POST can receive Kindryn events. Zapier and Make (formerly Integromat) are the two no-code automation platforms we explicitly test and document here, but the same setup works for n8n, Pipedream, Pabbly, or a Node/Deno script of your own.

This guide covers:

  1. How the webhook envelope looks
  2. Zapier setup walkthrough
  3. Make.com setup walkthrough
  4. Pre-built recipe ideas
  5. Event payload reference
  6. Verifying the signature on the receiving end

#1. The webhook envelope

Every delivery Kindryn sends is a JSON POST with this exact shape:

{
  "event": "onMemberJoin",
  "payload": { "communityId": "...", "memberId": "...", ... },
  "deliveryId": "whd_...",
  "timestamp": "2026-04-11T14:02:18.472Z"
}

And these headers:

HeaderExampleNotes
Content-Typeapplication/jsonAlways.
User-AgentKindryn-Webhook/1.0Useful for filtering in logs.
X-Kindryn-EventonMemberJoinMirrors event in the body for quick routing.
X-Kindryn-Deliverywhd_01HX...Unique ID — use for idempotency on your side.
X-Kindryn-Signaturesha256=4f2e...HMAC-SHA256 of the raw body, hex-encoded.

Retries: if your endpoint returns a non-2xx or the request times out (10s), Kindryn retries up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s). Each attempt reuses the same deliveryId, so your consumer should dedupe on that ID.


#2. Zapier setup walkthrough

Zapier's free Webhooks by Zapier trigger speaks HTTP POST out of the box, so no Kindryn-specific Zapier app is required.

  1. In Zapier: click _Create Zap_, pick Webhooks by Zapier as the trigger app, and choose the Catch Hook event. Zapier gives you a URL that looks like https://hooks.zapier.com/hooks/catch/1234567/abcde/. Copy it.
  2. In Kindryn: open your community, go to Settings -> Webhooks (admin only), click New webhook, and paste the Zapier URL into _Endpoint URL_. Name it Zapier — <what it does> so future-you knows what it powers.
  3. Pick events: check only the events you want this Zap to receive. Each event type should usually get its own webhook (and its own Zap) — mixing them makes the Zapier filter step more complex.
  4. Save the webhook. Kindryn auto-generates a signing secret — you can copy it now if you plan to verify signatures in a _Code by Zapier_ step (see §6). Most simple Zaps skip verification because Zapier hook URLs are already secret.
  5. Generate a test event: back in Kindryn, click the Test button on the webhook row. Kindryn fires a synthetic event: "test" delivery through the same retry pipeline. In Zapier, click _Test Trigger_ — you should see the sample payload appear within a few seconds.
  6. (Alternative) Copy a realistic test payload: click Copy sample payload on the webhook card, pick the event you care about, and paste the JSON into Zapier's _Raw Body_ field if you want a more realistic example than the test synthetic payload.
  7. Build your action step in Zapier — Mailchimp, Google Sheets, Slack, Notion, Airtable, etc. Map fields from payload.*.

#Zapier gotchas

  • Zapier parses nested JSON automatically: payload.authorName shows up as its own field in the UI. No need for a JSON parser step.
  • If you hit Zapier's 1-task-per-minute polling limit on a free plan, that's a plan limit, not a Kindryn limit — Kindryn delivers instantly.
  • Zapier's _Test_ payloads are cached. After editing your Kindryn webhook's event list, click _Test Trigger_ again to refresh the sample.

#3. Make.com setup walkthrough

Make calls webhooks Custom Webhooks. The flow mirrors Zapier's.

  1. In Make: create a new scenario, add the Webhooks module, pick Custom webhook, and click _Add_. Name it something like Kindryn — new member. Make assigns a URL like https://hook.us1.make.com/abcd.... Copy it.
  2. In Kindryn: _Settings -> Webhooks -> New webhook_. Paste the Make URL, pick your events, save.
  3. Determine data structure: Make needs to see at least one payload before it can map fields. Click Redetermine data structure in Make, then either:
  • Click Test on the Kindryn webhook row, or
  • Trigger the real event (create a post, add a member, etc.)
  1. Make captures the sample and the fields become available for downstream modules. Connect a router, a Slack module, a Google Sheets row — whatever your automation needs.
  2. Turn the scenario ON and set a schedule. Make's webhook modules run instantly on receipt (not on a schedule), so "On demand" is fine.

#Make gotchas

  • Make rejects a payload if the scenario is off. Turn the scenario on before clicking Kindryn's Test button, or the test delivery will retry 5 times and then mark itself FAILED.
  • Make's webhook queue holds deliveries briefly if the scenario is paused. If you see delayed events, check the scenario's queue.

#4. Pre-built recipe ideas

These are the flows most Kindryn admins wire up first. Each one is listed with the Kindryn event it subscribes to and the downstream app you'd plug into. We don't ship actual zap templates (Zapier's template sharing is opinionated about branding) but these recipes are the mental model to copy.

RecipeTrigger eventDestination
New member -> email listonMemberJoinMailchimp / ConvertKit / Buttondown add-subscriber
New member -> CRM contactonMemberJoinHubSpot / Attio / Notion create row
New post -> Slack channelonPostCreateSlack _Send channel message_ — embed title + excerpt
New post -> Discord channelonPostCreateDiscord webhook with an embed
New comment -> Slack threadonCommentCreateSlack _Reply to thread_ keyed by post ID
Event created -> Notion calendaronEventCreateNotion create-database-row
RSVP -> Google Sheet attendance logonEventRsvpGoogle Sheets append-row
Course enroll -> Mailchimp tagonCourseEnrollMailchimp _Add tag to subscriber_
Lesson complete -> ConvertKit sequenceonLessonCompleteConvertKit _Subscribe to sequence_
Coaching booked -> Calendly-style DMonCoachingBookSlack DM / Twilio SMS to coach
Member joined -> Stripe customer noteonMemberJoinStripe _Create customer note_
Post created -> Buffer queueonPostCreateBuffer _Add to queue_ for cross-posting

Every one of these is a single-trigger, single-action Zap or Make scenario. You shouldn't need filter or code steps for the common case.


#4b. Partner SSO handoff (seamless entry)

A partner platform that sells access on Kindryn's behalf (e.g. an event ticketing system) can drop a buyer straight into the community already authenticated — no second signup. This is a sequence of three public-API calls (full reference in docs/API.md), not a webhook:

  1. POST /members/ensure (members:write) — idempotently provision the buyer as a member from their email. Returns status: "provisional" for a freshly created, never-claimed account.
  2. POST /spaces/:spaceId/access (spaces:write) — grant the member access to the event's space(s).
  3. POST /auth/login-link (auth:login-link) — get a single-use URL. For a provisional account you get 201 { url }; hand it to the buyer and opening it logs them in and lands them in the space.

Security boundary. auth/login-link will not silently authenticate an email that already maps to a real, claimed Kindryn account — that would be an account-takeover vector. Such requests return `409 { reason: "account_exists",

fallbackUrl }; the fallbackUrl` is an invitation-accept link that proves email

possession before granting access. Login-link tokens are single-use and expire in ≤5 minutes.


#4c. Embedded ticket portal + reverse SSO (Eventicus Flow 3)

Kindryn can host a partner's (e.g. Eventicus) ticket-management portal inside the member area so members manage/buy/upgrade tickets without leaving the community. Kindryn vouches for the logged-in member with a short-lived signed identity token (reverse SSO — the mirror of §4b, but Kindryn→partner).

Setup (admin): _Admin → Eventicus_ (enable the eventicus community feature first). Paste the partner-provided embed URL and click Generate secret — Kindryn shows a per-community signing secret once; paste it into the partner's embed settings. Rotating it (Regenerate) invalidates outstanding tokens.

Runtime handshake:

  1. A member opens /<community>/tickets. The page loads the embed URL in an iframe and calls GET /api/communities/<slug>/integrations/eventicus/token (member session-authed) → { token, embedUrl, origin, expiresAt }.
  2. The page hands the token to the iframe via `postMessage({ type:

'kindryn-identity', token }, origin) — **never** in the URL. The partner page listens for it: `js window.addEventListener('message', (e) => { if (e.origin !== KINDRYN_ORIGIN) return if (e.data?.type === 'kindryn-identity') verify(e.data.token) }) ``

  1. The partner verifies the token with the shared secret and trusts the identity.

Token (HS256 JWT, ~5-min TTL). Header {"alg":"HS256","typ":"JWT"}; claims:

{
  "sub": "<kindryn userId>",
  "email": "[email protected]",
  "name": "Member Name",
  "communityId": "cmt_abc123",
  "communitySlug": "acme",
  "iss": "kindryn",
  "aud": "eventicus",
  "iat": 1718740000,
  "exp": 1718740300
}

Verification recipe (partner side):

import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(token, sharedSecret) {
  const [h, p, sig] = token.split('.')
  const expected = createHmac('sha256', sharedSecret).update(`${h}.${p}`).digest('base64url')
  const a = Buffer.from(sig),
    b = Buffer.from(expected)
  if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad signature')
  const claims = JSON.parse(Buffer.from(p, 'base64url').toString())
  if (claims.exp * 1000 < Date.now()) throw new Error('expired')
  if (claims.iss !== 'kindryn' || claims.aud !== 'eventicus') throw new Error('bad iss/aud')
  return claims // trust claims.email / claims.sub
}

Security. The secret is generated by Kindryn, stored AES-GCM-encrypted, and never returned in plaintext after the one reveal. Tokens are short-lived and minted only for the actual logged-in Kindryn member (the iframe can't forge them — it never sees the secret). Pin e.origin to Kindryn's origin on the partner's message listener.


#4d. Comp grants that follow the member (Eventicus Flow 4)

A complimentary ticket issued in a partner system (Eventicus) can follow the Kindryn member around — a slim banner on every community page ("🎟️ You have a comp ticket: <label> — Redeem →") — until they redeem it, then it clears. The partner pushes the grant in and pushes the redemption back (two comp-grants:write calls; full reference in docs/API.md). The banner respects the community's Eventicus ticket portal feature toggle — when the feature is off, the member-facing grants read 404s and the banner never renders (grants already pushed are kept; they surface again if the feature is re-enabled).

  1. Push the grantPOST /api/public/v1/comp-grants with the member's email (or userId), a unique code, a display label, and the redeemUrl (the partner's personal redeem deep-link). Idempotent on code; the member must already exist (call POST /members/ensure first).
  2. Member sees the banner — on every community page, linking out to redeemUrl (opens the partner redeem flow in a new tab).
  3. Push the redemption — when the member redeems on the partner side, POST /api/public/v1/comp-grants/<code>/redeem. The banner clears (also re-checked when the member returns focus to the Kindryn tab).

Grants with a past expiresAt stop showing automatically.


#5. Event payload reference

All lifecycle events are documented below with a sample payload you can paste into a Zapier / Make test step. You can also call GET /api/communities/:slug/webhooks/sample-payload?event=<name> on your own Kindryn instance (admin-only) to get a live sample, or omit event to get every sample in one JSON blob keyed by event name.

Every sample is wrapped in the standard delivery envelope shown in §1.

#onMemberJoin

Fired when a new member joins the community (invite accepted, open signup, or admin add).

{
  "event": "onMemberJoin",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "memberId": "mem_abc123",
    "userId": "usr_abc123",
    "email": "[email protected]",
    "name": "Jordan Rivera",
    "role": "MEMBER",
    "joinedAt": "2026-04-11T14:02:18.472Z"
  },
  "deliveryId": "whd_abc123",
  "timestamp": "2026-04-11T14:02:18.472Z"
}

#onMemberLeave

Fired when a member leaves or is removed from the community.

{
  "event": "onMemberLeave",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "memberId": "mem_abc123",
    "userId": "usr_abc123",
    "email": "[email protected]",
    "name": "Jordan Rivera",
    "leftAt": "2026-04-11T14:02:18.472Z"
  }
}

#onPostCreate

Fired when a post is published in any space.

{
  "event": "onPostCreate",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "postId": "pst_abc123",
    "spaceId": "spc_abc123",
    "spaceSlug": "general",
    "authorId": "mem_abc123",
    "authorName": "Jordan Rivera",
    "title": "Welcome to the community!",
    "excerpt": "Excited to be here and start learning from everyone...",
    "createdAt": "2026-04-11T14:02:18.472Z"
  }
}

#onPostDelete

Fired when a post is soft-deleted.

{
  "event": "onPostDelete",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "postId": "pst_abc123",
    "spaceId": "spc_abc123",
    "deletedBy": "mem_abc123",
    "deletedAt": "2026-04-11T14:02:18.472Z"
  }
}

#onCommentCreate

Fired when a comment is added to a post.

{
  "event": "onCommentCreate",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "commentId": "cmt_abc123",
    "postId": "pst_abc123",
    "authorId": "mem_abc123",
    "authorName": "Jordan Rivera",
    "excerpt": "Great post — thanks for sharing!",
    "createdAt": "2026-04-11T14:02:18.472Z"
  }
}

#onEventCreate

Fired when an event is created in any space.

{
  "event": "onEventCreate",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "eventId": "evt_abc123",
    "spaceId": "spc_abc123",
    "spaceSlug": "events",
    "title": "Monthly office hours",
    "description": "Bring your questions to the group Q&A.",
    "startsAt": "2026-04-18T14:02:18.472Z",
    "endsAt": "2026-04-18T15:02:18.472Z",
    "createdAt": "2026-04-11T14:02:18.472Z"
  }
}

#onEventRsvp

Fired when a member RSVPs to an event (GOING, MAYBE, NOT_GOING, or removed).

{
  "event": "onEventRsvp",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "eventId": "evt_abc123",
    "memberId": "mem_abc123",
    "status": "GOING",
    "rsvpedAt": "2026-04-11T14:02:18.472Z"
  }
}

#onCourseEnroll

Fired when a member enrolls in a course.

{
  "event": "onCourseEnroll",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "courseId": "crs_abc123",
    "memberId": "mem_abc123",
    "enrolledAt": "2026-04-11T14:02:18.472Z"
  }
}

#onLessonComplete

Fired when a member marks a lesson complete.

{
  "event": "onLessonComplete",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "lessonId": "lsn_abc123",
    "courseId": "crs_abc123",
    "memberId": "mem_abc123",
    "completedAt": "2026-04-11T14:02:18.472Z"
  }
}

#onCoachingBook

Fired when a member books a coaching session.

{
  "event": "onCoachingBook",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "sessionId": "ses_abc123",
    "memberId": "mem_abc123",
    "coachId": "mem_coach1",
    "startsAt": "2026-04-13T14:02:18.472Z",
    "endsAt": "2026-04-13T14:32:18.472Z",
    "bookedAt": "2026-04-11T14:02:18.472Z"
  }
}

#onInstall / onUninstall / onEnable / onDisable

Plugin lifecycle events. Useful for ops dashboards and audit trails.

{
  "event": "onInstall",
  "payload": {
    "communityId": "cmt_abc123",
    "communitySlug": "acme",
    "installationId": "pin_abc123",
    "pluginSlug": "sample-plugin",
    "pluginName": "Sample Plugin",
    "installedAt": "2026-04-11T14:02:18.472Z"
  }
}

#onSpaceLifecycle

Fired when a space's lifecycle changes — on create, and on archive (manual delete or ephemeral auto-expiry). Lets a partner (e.g. Eventicus) track when an event's pop-up space begins and ends. lifecycleEvent is one of created | archived | ephemeral_expiring (the last is reserved — declared but not yet emitted).

{
  "event": "onSpaceLifecycle",
  "payload": {
    "communityId": "cmt_abc123",
    "spaceId": "spc_abc123",
    "space": {
      "id": "spc_abc123",
      "slug": "vip-lounge",
      "name": "VIP Lounge",
      "ephemeral": true,
      "ephemeralExpiresAt": "2026-07-01T00:00:00.000Z"
    },
    "lifecycleEvent": "archived"
  },
  "deliveryId": "whd_abc123",
  "timestamp": "2026-07-01T00:05:00.000Z"
}

#onEnrollmentCompleted

Fired when a program enrollment first reaches a paid/ACTIVE state — i.e. the sale closed (self-serve checkout cleared, back-of-room deposit collected, or an admin activated/comped the enrollment). Built for at-event sales reporting: carries the member, plan, money (deal size + cash collected so far), sales-rep + affiliate attribution, and the originating-event context. The same shape is queryable via GET /api/public/v1/enrollments (see docs/API.md).

{
  "event": "onEnrollmentCompleted",
  "payload": {
    "communityId": "cmt_abc123",
    "enrollmentId": "enr_abc123",
    "member": { "memberId": "mbr_1", "userId": "usr_1", "email": "[email protected]", "name": "Buyer" },
    "plan": {
      "id": "pln_1",
      "name": "Flagship",
      "planType": "FIXED_TERM",
      "paymentSchedule": "INSTALLMENTS"
    },
    "money": { "contractValueCents": 499700, "collectedToDateCents": 99700, "currency": "usd" },
    "enrollment": {
      "id": "enr_abc123",
      "status": "ACTIVE",
      "saleMode": "LIVE_EVENT",
      "termStartsAt": "2026-06-01T00:00:00.000Z",
      "termEndsAt": "2027-06-01T00:00:00.000Z"
    },
    "attribution": {
      "salesRep": { "memberId": "mbr_rep", "userId": "usr_rep", "name": "Rep Roe" },
      "affiliate": { "affiliateId": "aff_1", "referralCode": "kref_abc123" }
    },
    "eventContext": {
      "sourceEventExternalId": "evt_42",
      "soldByMemberId": "mbr_rep",
      "saleChannel": "back_of_room"
    }
  },
  "deliveryId": "whd_def456",
  "timestamp": "2026-06-01T18:02:00.000Z"
}

attribution.salesRep = the closing rep (or the plan's default sales coach if none); attribution.affiliate = the depth-0 affiliate commission for the sale (both null when absent). eventContext is null for unattributed sales. Dedupe on enrollmentId (or the envelope deliveryId).

#onEnrollmentStatusChanged

Fired on subsequent enrollment lifecycle transitions — pause, resume, cancel, complete. Identical payload to onEnrollmentCompleted plus a previousStatus field; enrollment.status is the new status.

{
  "event": "onEnrollmentStatusChanged",
  "payload": {
    "communityId": "cmt_abc123",
    "enrollmentId": "enr_abc123",
    "previousStatus": "ACTIVE",
    "enrollment": {
      "id": "enr_abc123",
      "status": "CANCELLED",
      "saleMode": "LIVE_EVENT",
      "termStartsAt": "2026-06-01T00:00:00.000Z",
      "termEndsAt": "2027-06-01T00:00:00.000Z"
    },
    "member": { "memberId": "mbr_1", "userId": "usr_1", "email": "[email protected]", "name": "Buyer" },
    "plan": {
      "id": "pln_1",
      "name": "Flagship",
      "planType": "FIXED_TERM",
      "paymentSchedule": "INSTALLMENTS"
    },
    "money": { "contractValueCents": 499700, "collectedToDateCents": 99700, "currency": "usd" },
    "attribution": { "salesRep": null, "affiliate": null },
    "eventContext": {
      "sourceEventExternalId": "evt_42",
      "soldByMemberId": "mbr_rep",
      "saleChannel": "back_of_room"
    }
  },
  "deliveryId": "whd_ghi789",
  "timestamp": "2026-08-01T12:00:00.000Z"
}

#6. Verifying the signature

Every Kindryn webhook carries an X-Kindryn-Signature header. The value is sha256=<hex> where <hex> is the HMAC-SHA256 of the raw request body using the webhook's signing secret.

Verify on the receiving end before trusting the payload — especially if your endpoint is publicly reachable.

#Node.js / Deno

import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyKindrynSignature(secret, header, rawBody) {
  if (!header || !header.startsWith('sha256=')) return false
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
  const provided = header.slice('sha256='.length)
  if (expected.length !== provided.length) return false
  return timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
}

Important: rawBody must be the exact bytes you received, before any JSON parsing. If you're using Express, use express.raw({ type: '*/*' }) on the webhook route (not express.json()) so the body middleware doesn't re-serialize it.

#Python

import hmac, hashlib

def verify_kindryn_signature(secret: str, header: str, raw_body: bytes) -> bool:
    if not header or not header.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    provided = header[len("sha256="):]
    return hmac.compare_digest(expected, provided)

#Zapier _Code by Zapier_ step (JavaScript)

const crypto = require('crypto')
const secret = 'paste-your-kindryn-secret-here'
const header = inputData.signature // map to X-Kindryn-Signature
const rawBody = inputData.rawBody // map to Raw Body (not parsed JSON)

const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const provided = header.replace('sha256=', '')

if (expected !== provided) {
  throw new Error('Invalid Kindryn signature — dropping event.')
}
output = { verified: true }

#Make.com _Tools -> HMAC_ module

Make has a built-in HMAC generator. Feed it:

  • Algorithm: SHA256
  • Key: your webhook's signing secret
  • Input: the raw request body from the Webhook trigger (available as 1.data on most webhook modules — not the parsed JSON)

Compare the result to X-Kindryn-Signature (stripping the sha256= prefix) in a filter step.


#Troubleshooting

SymptomLikely cause / fix
Zapier says "Hook not received"Kindryn hasn't fired yet — click _Test_ on the Kindryn webhook row.
All deliveries marked FAILED with HTTP 401Zapier / Make URL is gated. Double-check the catch URL is public.
Signature never matchesYou're verifying against the parsed JSON, not the raw body. Use the raw bytes.
Events fire sometimes but not alwaysYour webhook is only subscribed to a subset of events — check the edit form.
Retry storm filling Make queueYour scenario is off. Turn it on, then redeliver via the Kindryn "Test" button.
"Delivery history is preserved" — where is it?_Settings -> Webhooks -> Logs_ button on each webhook row. Full request/response.

Feature matrix / status: see the Integrations & Comms section of FEATURES.md at the repository root.

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