Example: a lunch bot in Node
This example builds Lunchbot, a complete app in one TypeScript file with no dependencies beyond Node itself. When someone writes @Lunchbot lunch in a channel, it:
- receives the
app.mentionedwebhook and verifies its signature; - posts a card asking where to get lunch, open for an hour;
- accepts each vote with a note only the voter sees;
- when the card closes, replaces it with the winner.
1. Create the app#
An org owner or admin creates the app in Settings → Integrations → Apps (see Create an app):
- Name: Lunchbot.
- What it may do: Messages that mention it and Post. It never needs to read the whole channel.
- Events it hears about: It is mentioned, Someone answers its card and Its card closes.
- How the app gets events: Webhook.
They copy the App token and the Signing secret, shown once, into your server’s environment.
2. Set up the project#
You need Node 20 or later.
mkdir lunchbot && cd lunchbot
npm init -y
npm install --save-dev tsx
Put the values in the environment. These are placeholders: use the real token and secret from step 1 and your server’s API host (see the base URL), and never commit them.
export BUILDIT_APP_TOKEN='buildit_app_EXAMPLE_REPLACE_ME'
export BUILDIT_SIGNING_SECRET='buildit_whsec_EXAMPLE_REPLACE_ME'
export BUILDIT_API_BASE='https://api.buildit.social/functions/v1/app-api/v1'
export PORT=8000
If you keep them in a .env file while you develop, add it to .gitignore.
3. The code#
Save this as lunchbot.ts:
// lunchbot.ts: "@Lunchbot lunch" posts a lunch vote; when it closes, the card shows the winner.
// Node 20 or later.
import { createServer, type IncomingMessage } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOKEN = requireEnv("BUILDIT_APP_TOKEN"); // buildit_app_…
const SECRETS = requireEnv("BUILDIT_SIGNING_SECRET").split(",").map((s) => s.trim()).filter(Boolean);
const API_BASE = requireEnv("BUILDIT_API_BASE").replace(/\/+$/, ""); // …/functions/v1/app-api/v1
const PORT = Number(process.env.PORT ?? 8000);
const PLACES = [
{ id: "tacos", label: "Tacos" },
{ id: "pho", label: "Pho" },
{ id: "salad", label: "Salad bar" },
];
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}.`);
return value;
}
// ---- Signatures: v1=<hex HMAC-SHA256(secret, "v1:" + timestamp + ":" + body)>
export function verifySignature(
timestamp: string | undefined,
signature: string | undefined,
body: Buffer, // the raw body, exactly as received
secrets: string[], // the signing secret, or the new and the old one while you switch
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
if (!timestamp || !/^[0-9]{1,12}$/.test(timestamp)) return false;
if (Math.abs(nowSeconds - Number(timestamp)) > 300) return false; // 5 minutes, either way
const sent = (signature ?? "").split(",")
.map((value) => value.trim())
.filter((value) => value.startsWith("v1="))
.map((value) => Buffer.from(value.slice(3)));
let ok = false;
for (const secret of secrets) {
const hmac = createHmac("sha256", secret).update(`v1:${timestamp}:`).update(body);
const expected = Buffer.from(hmac.digest("hex"));
// Check every value, in constant time, without stopping at the first match.
for (const value of sent) {
if (value.length === expected.length && timingSafeEqual(value, expected)) ok = true;
}
}
return ok;
}
// ---- The app API
async function api(method: string, path: string, body?: unknown): Promise<any> {
const res = await fetch(API_BASE + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${json?.error?.code ?? ""}`);
return json;
}
// ---- Cards
function lunchCard(closesAt: Date) {
return {
v: 1,
header: { title: "Where should we get lunch?", subtitle: "Voting closes in 1 hour" },
inputs: [{ type: "choice", id: "place", label: "Pick one", required: true, options: PLACES }],
actions: [{ type: "submit", label: "Vote" }],
// Everyone sees who picked what, as votes come in, and can change their vote until it closes.
responses: { mode: "changeable", visibility: "shared", results: "live", closes_at: closesAt.toISOString() },
// Comes back with every card.response and card.closed; never shown to anyone.
private: { kind: "lunch" },
};
}
function resultCard(winner: string, votes: number) {
return {
v: 1,
header: { title: "Lunch is decided" },
body: [{ type: "facts", items: [{ label: "Winner", value: winner }, { label: "Votes", value: String(votes) }] }],
};
}
// ---- Events
function later(work: Promise<unknown>): void {
work.catch((error) => console.error("lunchbot:", error.message));
}
function handle(event: any): unknown {
const data = event.data;
switch (event.type) {
case "url_verification": // sent when an admin verifies the webhook URL
return { challenge: data.challenge };
case "app.mentioned": {
// "[@Lunchbot](mention:<app id>) lunch": the mention is a Markdown link to the app's id.
const mention = new RegExp(`\\[@[^\\]]*\\]\\(mention:${event.app.id}\\)`, "gi");
const command = data.message.text.replace(mention, "").trim().toLowerCase();
if (command !== "lunch") return {};
later(api("POST", `/channels/${data.channel.id}/messages`, {
text: "Where should we get lunch? Vote in buildIt.social.", // the card's fallback
card: lunchCard(new Date(Date.now() + 60 * 60 * 1000)),
thread_id: data.message.thread_id ?? undefined, // answer in the same thread, if it was one
reply_to_id: data.message.id,
idempotency_key: event.id, // a redelivered event never posts twice
}));
return {};
}
case "card.response":
// The inline answer: only the person who voted sees this note.
if (data.private?.kind !== "lunch") return {};
return { status: "accepted", note: "Got it. You can change your vote until the card closes." };
case "card.closed": {
if (data.private?.kind !== "lunch" || data.closed_by === "deleted") return {};
const counts: Record<string, number> = data.counts?.place ?? {};
const [winnerId, votes] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] ?? ["", 0];
const winner = PLACES.find((p) => p.id === winnerId)?.label;
if (!winner || votes === 0) return {};
later(api("PATCH", `/messages/${data.message.id}`, {
text: `Lunch: ${winner}, with ${votes} ${votes === 1 ? "vote" : "votes"}.`,
card: resultCard(winner, votes),
}));
return {};
}
default: // events this app doesn't use, and new types added later
return {};
}
}
// ---- The webhook server
const replies = new Map<string, unknown>(); // event id → our reply, so a redelivery gets the same answer
function header(req: IncomingMessage, name: string): string | undefined {
const value = req.headers[name];
return Array.isArray(value) ? value[0] : value;
}
createServer(async (req, res) => {
if (req.method !== "POST") return void res.writeHead(405).end();
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks);
if (!verifySignature(header(req, "x-buildit-timestamp"), header(req, "x-buildit-signature"), body, SECRETS)) {
return void res.writeHead(401).end("bad signature");
}
const event = JSON.parse(body.toString("utf8"));
if (!replies.has(event.id)) {
replies.set(event.id, handle(event));
if (replies.size > 1000) replies.delete(replies.keys().next().value!);
}
// Answer within 5 seconds; the API calls above run after this reply.
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(replies.get(event.id)));
}).listen(PORT, () => console.log(`lunchbot: listening on port ${PORT}`));
How it works#
- The raw body is verified first.
verifySignaturecomputesHMAC-SHA256(secret, "v1:" + timestamp + ":" + body)over the exact bytes received, refuses a timestamp more than 5 minutes off, and compares in constant time. It accepts anyv1=value, so it keeps working while an admin rotates the secret (pass both secrets, comma-separated, during the switch). See Verifying signatures. - It answers within 5 seconds. The reply goes out at once; the API calls run after it.
- It deduplicates. Delivery is at least once, so a redelivered event gets the same reply from
replies, and posting uses the event’s id asidempotency_key, so a retry never posts twice. - The mention is a Markdown link to the app’s id,
[@Lunchbot](mention:<app id>), so it’s stripped before reading the command. - The card is a form with one required choice and a Vote button. It’s
sharedwithliveresults, so everyone sees who picked what as votes come in, andchangeable, so people can change their vote untilcloses_at. See The card format. privatecomes back with everycard.responseandcard.closed, so the app knows the card is a lunch vote without keeping its own records.- The inline answer
{"status": "accepted", "note": "…"}in the reply tocard.responseshows the note under the card, to the voter only. - When the card closes,
card.closedcarries the finalcountsby input id, then option id ({"place": {"tacos": 1, "pho": 2}}). The app replaces the card with a result card that has no inputs, which is allowed on a closed card.
4. Run it#
npx tsx lunchbot.ts
On Node 22.18 or later you can also run node lunchbot.ts.
The app must be reachable on a public https:// URL on port 443. While you develop, put it behind an HTTPS tunnel you trust, for example:
cloudflared tunnel --url http://localhost:8000
5. Verify the webhook URL#
The admin opens the app’s page, enters the public URL as the Webhook URL and selects Verify. buildIt.social sends a signed url_verification event, and Lunchbot answers with the same challenge. They see The webhook URL is verified. See Manage an app.
6. Try it#
- Add Lunchbot to a channel: open the channel’s details, then Add app in the Apps section.
- Write @Lunchbot lunch, picking Lunchbot from the mention list.
- Lunchbot posts the card. Vote, and you see “Lunchbot: Got it. You can change your vote until the card closes.” with Only you can see this.
- After an hour, the card closes and Lunchbot replaces it with the winner.
Where to go from here#
- Close it early:
POST /v1/messages/{id}/card/closecloses the card now, and the app getscard.closedwithclosed_by: "app". - Answer later: an app that does slow work answers with
PATCH /v1/responses/{id}instead of in the reply. - No public URL? Ask your admin to switch the app to Polling, and read events with
GET /v1/events. See Polling. - Handle errors: the API’s error codes and rate limits are on The app API.