Webhooks

Receiver recipes

Copy-paste patterns that come up most often in production. Signature verification is included in every one.

Recipe 1 — send a KakaoTalk notification

On a status change, notify everyone connected to the idea over KakaoTalk — by far the most common setup.

Express + KakaoTalk
js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import express from "express";

const app = express();

app.post(
  "/lenit-webhook",
  express.json(),
  async (req, res) => {
    const event = req.body;

    // 1) Only handle status change / announcement published events
    const isAudienceEvent =
      event.event === "idea.status_changed" ||
      event.event === "announcement.published";
    if (!isAudienceEvent || !event.data.audience) {
      return res.status(200).send("skip");
    }

    // 2) Merge the audience groups and dedupe
    const a = event.data.audience;
    const recipients = [
      a.author,
      ...(a.commenters || []),
      ...(a.voters     || []),
      ...(a.reactors   || []),
    ].filter(Boolean);

    const seen = new Set();
    const unique = recipients.filter(u => {
      if (seen.has(u.externalId)) return false;
      seen.add(u.externalId);
      return true;
    });

    // 3) Look up the phone number in your own DB, then send a KakaoTalk message
    for (const user of unique) {
      const profile = await db.users.findByExternalId(user.externalId);
      if (!profile?.phone) continue;

      await kakaoAlimtalk.send({
        to: profile.phone,
        templateCode: "IDEA_STATUS_CHANGED",
        variables: {
          name: profile.name,
          ideaTitle: event.data.title,
          newStatus: event.data.newStatus,
        },
      });
    }

    res.status(200).send("ok");
  }
);

app.listen(3000);

Recipe 2 — route by traits

KakaoTalk for plan: "pro" users, email for everyone else.

js
1
2
3
4
5
6
7
8
9
for (const user of unique) {
  const channel = user.traits?.plan === "pro" ? "kakao" : "email";

  if (channel === "kakao") {
    await sendKakaoAlimtalk(user.externalId, event.data);
  } else {
    await sendEmail(user.externalId, event.data);
  }
}

Recipe 3 — push to heavy users only

js
1
2
3
4
5
6
7
8
9
10
const heavyUsers = unique.filter(
  u => u.traits?.userType === "heavy"
);

for (const user of heavyUsers) {
  await sendPush(user.externalId, {
    title: event.eventLabel,                     // "Idea status changed"
    body: `${event.data.title} → ${event.data.newStatus}`,
  });
}

Recipe 4 — post to Discord (Slack and JANDI convert automatically)

Discord isn't converted automatically, so you'll need to format the payload yourself.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function forwardToDiscord(event) {
  const data = event.data;
  const audienceCount =
    (data.audience?.author ? 1 : 0) +
    (data.audience?.commenters?.length || 0) +
    (data.audience?.voters?.length || 0) +
    (data.audience?.reactors?.length || 0);

  await fetch(process.env.DISCORD_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      embeds: [{
        title: event.eventLabel,
        description: `**${data.title}**\n${data.oldStatus} → ${data.newStatus}`,
        color: 0x16265C,
        footer: { text: `Recipients: ${audienceCount}` },
        timestamp: data.updatedAt,
      }],
    }),
  });
}

Recipe 5 — n8n / Zapier

n8n and Zapier handle this in their GUI, no code required.

  1. Create a Webhook trigger node in n8n and copy its URL
  2. Register that URL under Settings → Webhooks in Lenit
  3. Chain whatever you need in n8n's visual builder — create a Notion page, send a Slack message, append to Google Sheets
TipPreviewing the payload shape
n8n and Zapier trigger nodes learn the payload schema from the first request they receive. Fire a test trigger from the Lenit admin — change one idea's status — to register the schema, then wire up the nodes that follow.

Recipe 6 — retry queue (recommended in production)

Lenit is fire-and-forget: if your server is down for a moment, that event is gone for good. Put a queue in front of it and run the retries yourself.

Retry queue with BullMQ + Redis
js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Queue, Worker } from "bullmq";
const queue = new Queue("lenit-webhooks", { connection: redis });

app.post("/lenit-webhook", express.json(), async (req, res) => {
  // Enqueue first and return 200 right away (avoids a timeout on Lenit's side)
  await queue.add("process", {
    event: req.body,
    receivedAt: Date.now(),
  }, {
    attempts: 5,
    backoff: { type: "exponential", delay: 1000 },
  });

  res.status(200).send("queued");
});

// A separate worker process
new Worker("lenit-webhooks", async (job) => {
  await processEvent(job.data.event);  // Send the actual notification
});

Debugging tips

SymptomWhat to check
No notification arrivesCheck the URL and the ticked events under Settings → Webhooks. Register webhook.site temporarily to confirm the payload arrives
audience is emptyConfirm the event is idea.status_changed or announcement.published and that audience targets are ticked. It comes through empty when only anonymous (fingerprint) visitors are involved
401 verification failureAutomatic parsing such as express.json() can destroy the raw body — use express.raw() instead
Signatures match but it still failsMake sure the sha256= prefix is part of the comparison, and check for stray whitespace

Next steps

←Audience structureMCP→