Developer documentation
Webhooks, signature verification, event payloads, and example receivers.
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.
Each community has one webhook endpoint. All 5 events are sent to it; you fan-out on your side.
Every request carries an X-Webhook-Signature header. Always verify it.
Up to 3 retries with exponential backoff over 24 hours. Make your handler idempotent.
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.
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." } 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.' } 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.'} 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.
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); 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 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)
} 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
Every delivery is a POST with Content-Type: application/json plus these custom headers:
Your webhook's public Client ID. Use it to look up the corresponding secret if you have multiple communities sharing one handler.
Unique ID for this delivery. Use it for idempotent processing — retries reuse the same ID.
One of member.joined, member.approved, member.rejected, member.removed, member.left, or webhook.test.
When the event actually occurred, not when delivered. Reject deliveries more than 5 minutes off your clock as a replay-protection measure.
sha256=<hex> — HMAC-SHA256 of the raw body, keyed with your client secret.
Always Key-Community-Webhooks/1.0. Useful for allow-listing on your edge.
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.
A complete Express handler that verifies the signature, deduplicates by event ID, and routes to per-event handlers.
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.
Fired when a new member submits the join form. Status changes from null → PENDING if approval is required, or null → APPROVED if not.
Always "member.joined".
Idempotency key. Mirrors X-Event-Id.
When the member joined.
The community the event belongs to — { id, name }.
The person who joined. Includes id, fullName, email, phone, linkedinUrl, companyName, companyStage.
{ old, new } showing the status transition. For joined, old is null.
Onboarding form answers, if the community has an onboarding flow. Each entry is { semantic_key, question, type, answer }.
{
"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"
}
]
} Fired when a pending member is approved by a moderator or admin. Status transitions from PENDING → APPROVED.
{
"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" }
} Fired when a pending member is rejected. Status transitions from PENDING → REJECTED. Includes an optional reason string.
{
"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."
} Fired when an admin removes a member. Status transitions from APPROVED → REMOVED.
Fired when a member voluntarily leaves the community. Status transitions from APPROVED → LEFT. No actor field is included.
Questions? developers@key.ai