Resources menu

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:

  1. receives the app.mentioned webhook and verifies its signature;
  2. posts a card asking where to get lunch, open for an hour;
  3. accepts each vote with a note only the voter sees;
  4. 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. verifySignature computes HMAC-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 any v1= 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 as idempotency_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 shared with live results, so everyone sees who picked what as votes come in, and changeable, so people can change their vote until closes_at. See The card format.
  • private comes back with every card.response and card.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 to card.response shows the note under the card, to the voter only.
  • When the card closes, card.closed carries the final counts by 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#

  1. Add Lunchbot to a channel: open the channel’s details, then Add app in the Apps section.
  2. Write @Lunchbot lunch, picking Lunchbot from the mention list.
  3. 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.
  4. 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/close closes the card now, and the app gets card.closed with closed_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.