Webhooks

Webhooks, signature verification, event payloads, and example receivers.

Webhooks

Webhooks overview

Subscribe to events from your Key community in real time. We'll POST a JSON payload to a URL you specify whenever a member joins, is approved, is rejected, is removed, or leaves.

One URL per community

Each community has one webhook endpoint. All 5 events are sent to it; you fan-out on your side.

HMAC-signed payloads

Every request carries an X-Webhook-Signature header. Always verify it.

At-least-once delivery

Up to 3 retries with exponential backoff over 24 hours. Make your handler idempotent.

Getting started with webhooks

Getting started

Webhooks are set up from Community Settings → Webhooks. An admin needs the webhooks.edit permission to add or change the URL. Once added, you'll be shown a Client ID and Client Secret — the secret is shown once, so store it in your secret manager before closing.

Open Community Settings → Webhooks.

Paste your endpoint URL (HTTPS only).

Copy the Client ID and Secret. The secret cannot be retrieved later.

Verify your endpoint with the call below — this confirms the connection.

Optionally Send test event to dry-run your handler.

Verifying your endpoint

cURL
curl -X POST https://api.key.community/v1/webhooks/verify \
  -H "Content-Type: application/json" \
  -H "X-Client-Id: $KEY_WEBHOOK_CLIENT_ID" \
  -d '{
    "communityId": "a9e2f12c-7c8d-4b3f-b9c1-2d6e3f5a8b10",
    "clientId":    "wh_8aF3kL2pNxRqYv9c",
    "clientSecret":"sk_3xZqW2tBnMv8KpYjL5hVcG1Rd"
  }'

# → 200 OK
# { "message": "Webhook endpoint verified successfully." }
Node.js
import { request } from 'undici';

const { body } = await request('https://api.key.community/v1/webhooks/verify', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Client-Id':  process.env.KEY_WEBHOOK_CLIENT_ID,
  },
  body: JSON.stringify({
    communityId:  process.env.KEY_COMMUNITY_ID,
    clientId:     process.env.KEY_WEBHOOK_CLIENT_ID,
    clientSecret: process.env.KEY_WEBHOOK_SECRET,
  }),
});

console.log(await body.json());
// { message: 'Webhook endpoint verified successfully.' }
Python
import os, requests

resp = requests.post(
    "https://api.key.community/v1/webhooks/verify",
    headers={"X-Client-Id": os.environ["KEY_WEBHOOK_CLIENT_ID"]},
    json={
        "communityId":  os.environ["KEY_COMMUNITY_ID"],
        "clientId":     os.environ["KEY_WEBHOOK_CLIENT_ID"],
        "clientSecret": os.environ["KEY_WEBHOOK_SECRET"],
    },
)
print(resp.json())
# {'message': 'Webhook endpoint verified successfully.'}

Verify webhook signatures

Signature verification

Every webhook delivery includes an X-Webhook-Signature header in the form sha256=<hex digest>. The digest is an HMAC-SHA256 of the raw request body, keyed with your client secret.

Node.js (Express)
import express from 'express';
import crypto  from 'node:crypto';

const app    = express();
const SECRET = process.env.KEY_WEBHOOK_SECRET;

app.post(
  '/webhooks/key',
  express.raw({ type: 'application/json' }), // raw Buffer, not parsed
  (req, res) => {
    const sig = req.headers['x-webhook-signature'] || '';
    const expected =
      'sha256=' +
      crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(req.body);
    // route by event.eventType…
    res.status(200).end();
  }
);

app.listen(3000);
Python (Flask)
import os, hmac, hashlib
from flask import Flask, request, abort

app    = Flask(__name__)
SECRET = os.environ["KEY_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/key")
def handle():
    body = request.get_data()                       # raw bytes
    sig  = request.headers.get("X-Webhook-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(sig, expected):
        abort(401)

    event = request.get_json(force=True)
    # route by event["eventType"]…
    return "", 200
Go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

var secret = []byte(os.Getenv("KEY_WEBHOOK_SECRET"))

func handle(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    mac     := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(r.Header.Get("X-Webhook-Signature")), []byte(expected)) {
        http.Error(w, "invalid signature", 401)
        return
    }
    // JSON-unmarshal and route…
    w.WriteHeader(200)
}

Delivery, retries & idempotency

Delivery & retries

Your endpoint should respond with a 2xx status within 8 seconds. Anything else is treated as a failure and queued for retry.

Retries: up to 3 attempts after the initial delivery

Backoff: 1 min, 5 min, 30 min, 2 hr, 8 hr (exponential, jittered)

Window: retries stop after 24 hours; the event is then marked failed

Replay: any failed delivery can be manually replayed from the activity log for up to 30 days

Order: events are not ordered. Use occurredAt to sort if you need a sequence

Webhook request headers

Request headers

Every delivery is a POST with Content-Type: application/json plus these custom headers:

X-Client-Id · string

Your webhook's public Client ID. Use it to look up the corresponding secret if you have multiple communities sharing one handler.

X-Event-Id · string (UUID)

Unique ID for this delivery. Use it for idempotent processing — retries reuse the same ID.

X-Event-Type · string

One of member.joined, member.approved, member.rejected, member.removed, member.left, or webhook.test.

X-Event-Timestamp · string (ISO 8601)

When the event actually occurred, not when delivered. Reject deliveries more than 5 minutes off your clock as a replay-protection measure.

X-Webhook-Signature · string

sha256=<hex> — HMAC-SHA256 of the raw body, keyed with your client secret.

User-Agent · string

Always Key-Community-Webhooks/1.0. Useful for allow-listing on your edge.

Webhook error responses

Error responses

The verify endpoint returns one of:

Status: 200 · Body: { "message": "Webhook endpoint verified successfully." } · When: Credentials match and the URL is reachable.

Status: 400 · Body: { "error": "invalid_payload", "message": "communityId is required" } · When: Request body is missing or malformed.

Status: 401 · Body: { "error": "invalid_credentials" } · When: Client ID or Secret doesn't match for the given community.

Status: 404 · Body: { "error": "webhook_not_found" } · When: The community doesn't have a webhook configured.

Status: 503 · Body: { "error": "endpoint_unreachable" } · When: Your endpoint didn't respond within 8 seconds during verification.

Example webhook receiver

Example receiver

A complete Express handler that verifies the signature, deduplicates by event ID, and routes to per-event handlers.

Node.js
import express from 'express';
import crypto  from 'node:crypto';

const app    = express();
const SECRET = process.env.KEY_WEBHOOK_SECRET;
const seen   = new Set();   // in prod, use Redis / a TTL store

function verify(rawBody, signature) {
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

app.post(
  '/webhooks/key',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig     = req.headers['x-webhook-signature'];
    const eventId = req.headers['x-event-id'];

    if (!sig || !verify(req.body, sig)) return res.sendStatus(401);
    if (seen.has(eventId))            return res.sendStatus(200); // dedup
    seen.add(eventId);

    const event = JSON.parse(req.body);

    switch (event.eventType) {
      case 'member.joined':   onJoined(event);   break;
      case 'member.approved': onApproved(event); break;
      case 'member.rejected': onRejected(event); break;
      case 'member.removed':  onRemoved(event);  break;
      case 'member.left':     onLeft(event);     break;
      case 'webhook.test':    /* no-op */      break;
    }

    res.sendStatus(200);
  }
);

app.listen(3000);

← In the product, this is also reachable from Community Settings → Developer → Webhooks.

Events

Webhook events reference

member.joined

Fired when a new member submits the join form. Status changes from null → PENDING if approval is required, or null → APPROVED if not.

Body fields

eventType · string

Always "member.joined".

eventId · string (UUID)

Idempotency key. Mirrors X-Event-Id.

occurredAt · string (ISO 8601)

When the member joined.

community · object

The community the event belongs to — { id, name }.

member · object

The person who joined. Includes id, fullName, email, phone, linkedinUrl, companyName, companyStage.

status · object

{ old, new } showing the status transition. For joined, old is null.

questions · array | null

Onboarding form answers, if the community has an onboarding flow. Each entry is { semantic_key, question, type, answer }.

Example body

JSON
{
  "eventType":  "member.joined",
  "eventId":    "evt_50b56daed0a3486fbe8350f9",
  "occurredAt": "2026-05-25T12:51:00.000Z",
  "community": {
    "id":   "a9e2f12c-7c8d-4b3f-b9c1-2d6e3f5a8b10",
    "name": "Founders Den"
  },
  "status": { "old": null, "new": "PENDING" },
  "member": {
    "id":           "mem_3f8c2b1aa7d44c0e9e1f",
    "fullName":     "Asha Verma",
    "email":        "asha@acme.io",
    "phone":        "+91-99887-72211",
    "linkedinUrl":  "https://www.linkedin.com/in/asha-verma",
    "companyName":  "Acme Labs",
    "companyStage": "seed"
  },
  "questions": [
    {
      "semantic_key": "why_joining",
      "question":     "Why are you joining?",
      "type":         "long_text",
      "answer":       "Looking to meet other early-stage founders."
    },
    {
      "semantic_key": "stage",
      "question":     "What stage is your company?",
      "type":         "single_choice",
      "answer":       "Seed"
    },
    {
      "semantic_key": "website",
      "question":     "What's your website?",
      "type":         "url",
      "answer":       "https://acme.io"
    }
  ]
}

member.approved

Fired when a pending member is approved by a moderator or admin. Status transitions from PENDING → APPROVED.

Example body

JSON
{
  "eventType":  "member.approved",
  "eventId":    "evt_b2f1a8d33e4b4f1aa4a1",
  "occurredAt": "2026-05-25T13:02:00.000Z",
  "community": { "id": "a9e2f12c-…", "name": "Founders Den" },
  "status":    { "old": "PENDING", "new": "APPROVED" },
  "actor":     { "id": "mem_…", "fullName": "Jorre R.", "role": "admin" },
  "member":    { "id": "mem_…", "fullName": "Asha Verma", "email": "asha@acme.io" }
}

member.rejected

Fired when a pending member is rejected. Status transitions from PENDING → REJECTED. Includes an optional reason string.

Example body

JSON
{
  "eventType":  "member.rejected",
  "eventId":    "evt_c79122eebaa8479ea7c0",
  "occurredAt": "2026-05-25T13:08:00.000Z",
  "community": { "id": "a9e2f12c-…", "name": "Founders Den" },
  "status":    { "old": "PENDING", "new": "REJECTED" },
  "actor":     { "id": "mem_…", "fullName": "Jorre R.", "role": "admin" },
  "member":    { "id": "mem_…", "fullName": "Asha Verma", "email": "asha@acme.io" },
  "reason":    "Off-topic application."
}

member.removed

Fired when an admin removes a member. Status transitions from APPROVED → REMOVED.

member.left

Fired when a member voluntarily leaves the community. Status transitions from APPROVED → LEFT. No actor field is included.

Questions? developers@key.ai