hasarakMinimalModern

Build a chat bot with a single HTTP endpoint

A chat bot doesn't need a socket connection, a framework or a server that stays up. This guide builds one as a single URL: verify the signature, answer the event, call the API back. Real code, with and without an SDK.

· 10 min read

Most chat bots are small. They watch for a pattern in a message, do one thing, and post an answer. The plumbing around that, though, is often bigger than the bot: a long-lived WebSocket, a framework with its own lifecycle, a process that has to stay up and reconnect.

There’s a simpler shape, and the big platforms have all moved toward it: the chat service POSTs to one HTTP endpoint you own, your endpoint answers with JSON, and when it needs to do more it calls the chat API back. That endpoint can be a Cloudflare Worker, a Lambda, a Bun process on a $5 VPS, or a route in an app you already run.

This guide walks through that pattern end to end, using Hasarak connectors for the code because their protocol is small enough to fit in this guide, and comparing each step to Slack and Discord so the ideas carry over.

The shape of a webhook bot

Every webhook-style bot does the same four things:

  1. Receives a POST from the chat service when something happens (a message, a slash command, a button press).
  2. Verifies the request came from the service, because anyone who finds the URL can POST to it.
  3. Does its work and answers quickly.
  4. Calls the chat API back with some credential to post a reply or read more context.

What differs between platforms is mostly step 2 and step 4: how requests are signed, and where the bot’s API credential comes from.

Slack (Events API) Discord (interactions endpoint) Hasarak (connector)
What gets POSTed Events you subscribed to, wrapped in event_callback Interactions only (slash commands, buttons), not plain messages Actions: install, refresh, event, tool, invoke, remove, …
Signature HMAC-SHA256 of v0:<timestamp>:<body> with a shared signing secret, X-Slack-Signature Ed25519, X-Signature-Ed25519 + X-Signature-Timestamp Ed25519, Hasarak-Signature + Hasarak-Timestamp
First request url_verification challenge to echo back PING you answer with PONG GET or a manifest action returns the manifest
API credential Bot token stored at install time Bot token stored at install time A fresh token in every request, valid for 5 minutes
Deadline and retries Acknowledge within 3 seconds; up to 3 retries (then 1 min, then 5 min) Respond within 3 seconds (or defer) Retried 3 times with backoff (250 ms, 500 ms, 1 s) on a non-2xx

Sources: Slack’s verifying requests and Events API docs; Discord’s interactions docs; Hasarak’s custom connector docs.

Discord takes verification seriously: it deliberately sends requests with invalid signatures to a new endpoint, and won’t accept the URL unless they get a 401. One more Discord caveat matters for bots that read chat: the interactions endpoint only receives interactions. A Discord bot that reacts to ordinary messages still needs a Gateway (WebSocket) connection. Slack’s Events API and Hasarak connectors both deliver message events over HTTP.

What we’re building

A small “status” bot. When someone writes !status https://example.com in a channel, it requests the URL and replies in the thread with the HTTP status and how long the request took:

alex:    deploy is out, can someone check? !status https://example.com/health
status:  https://example.com/health → 200 OK in 182 ms

It’s deliberately boring so the plumbing stays visible. (It also requests any URL a member types, so if you run it inside your own network, block private addresses first.) We’ll write it twice: once against the raw protocol, so you can port it to any language, and once with the TypeScript SDK. Then we’ll expose the same check as a tool that AI agents can call over MCP.

Version 1: the raw protocol, no dependencies

This runs under Bun, Deno, Node 22+ or a Cloudflare Worker: it only uses fetch, Request, Response and WebCrypto.

The manifest

A connector describes itself with a manifest: a name, a title, a description and the permissions it needs. Hasarak asks for it when an admin installs the connector by URL, either with a plain GET or with a POST whose action is manifest.

const NAME = "status";

const manifest = (url: string) => ({
  name: NAME,
  title: "Status",
  description: "Replies to !status <url> with the URL's HTTP status and latency",
  url,
  permissions: ["messages:read", "messages:write"],
});

The permissions are the whole access model. Hasarak has six: channels:read, messages:read, messages:write, pages:read, pages:write and connector:<name> (to call another connector). An API call outside them answers 403. The bot posts as its own member, conn-status, so its messages are labelled, and it can’t see DMs.

Verifying the signature

Hasarak signs every action except manifest with Ed25519. The signed bytes are v1:<timestamp>:<connector name>:<raw body>; putting the connector’s name in there means a request captured for one connector won’t verify at another, and the timestamp bounds replays to five minutes.

const b64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));

async function verified(req: Request, raw: string, publicKey: string): Promise<boolean> {
  const ts = req.headers.get("hasarak-timestamp") ?? "";
  const sig = req.headers.get("hasarak-signature") ?? "";
  if (!/^\d{1,12}$/.test(ts) || !sig) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  // The key is SPKI, as PEM or bare base64.
  const der = b64(publicKey.replace(/-----[^-]+-----|\s/g, ""));
  const key = await crypto.subtle.importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
  const data = new TextEncoder().encode(`v1:${ts}:${NAME}:${raw}`);
  return crypto.subtle.verify({ name: "Ed25519" }, key, b64(sig), data).catch(() => false);
}

Three details that bite people on every platform, not just this one:

  • Verify the raw body, byte for byte, before parsing it. If a framework parses and re-serialises the JSON first, key order or whitespace changes and the signature won’t match.
  • Check the timestamp before trusting the signature, or a captured request can be replayed forever.
  • Trust nothing in the body until it verifies, including the API URL and the token in it.

crypto.subtle.verify compares in constant time; don’t compare signatures with === when you implement HMAC schemes like Slack’s either.

Handling actions

After verification, the body says which action this is. The bot cares about two:

  • install: answer with a metadata action that subscribes to message.created.
  • event: a subscribed event happened. The body carries the event and, for message events, the message itself.
type Body = {
  action: string;
  connector: { token: string };
  api: { url: string };
  event?: { event: { type: string }; message?: { id: string; parentId?: string; text?: string } };
};

const STATUS = /!status\s+(https?:\/\/\S+)/;

async function check(url: string): Promise<string> {
  const started = Date.now();
  try {
    const r = await fetch(url, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(5000) });
    return `${url} → ${r.status} ${r.statusText} in ${Date.now() - started} ms`;
  } catch (err) {
    return `${url} → failed after ${Date.now() - started} ms (${(err as Error).message})`;
  }
}

async function handle(body: Body): Promise<unknown> {
  if (body.action === "install" || body.action === "refresh") {
    return { actions: [{ type: "metadata", metadata: {}, subscribe: ["message.created"] }] };
  }
  if (body.action !== "event") return {};

  const message = body.event?.message;
  const match = message?.text?.match(STATUS);
  if (!message || !match) return {};

  // Reply in the message's thread (or the thread it's already in).
  const thread = message.parentId ?? message.id;
  const r = await fetch(`${body.api.url}/v1/messages/${encodeURIComponent(thread)}/replies`, {
    method: "POST",
    headers: { authorization: `Bearer ${body.connector.token}`, "content-type": "application/json" },
    body: JSON.stringify({ text: await check(match[1]) }),
  });
  if (!r.ok) throw new Error(`reply failed: ${r.status}`);
  return {};
}

Notice what’s missing: no stored bot token, no OAuth dance, no database. Every action carries connector.token, a bearer token that works for five minutes and only within the permissions the workspace granted. If the bot is uninstalled, the next request simply never comes.

The endpoint

export default {
  async fetch(req: Request, env: { HASARAK_PUBLIC_KEY: string }): Promise<Response> {
    if (req.method === "GET") return Response.json(manifest(req.url));
    if (req.method !== "POST") return new Response(null, { status: 405 });

    const raw = await req.text();
    const body = JSON.parse(raw) as Body;
    if (body.action === "manifest") return Response.json({ manifest: manifest(req.url) });
    if (!(await verified(req, raw, env.HASARAK_PUBLIC_KEY))) {
      return Response.json({ error: "bad signature" }, { status: 401 });
    }
    try {
      return Response.json(await handle(body));
    } catch (err) {
      return Response.json({ error: (err as Error).message }, { status: 500 });
    }
  },
};

That default export is the Worker module format, so wrangler deploy ships it as is. Under Bun, wrap it:

import bot from "./bot";
Bun.serve({ port: 8787, fetch: (req) => bot.fetch(req, { HASARAK_PUBLIC_KEY: process.env.HASARAK_PUBLIC_KEY! }) });

Two things the platform handles for you

Retries. A non-2xx answer, or an endpoint that can’t be reached, is retried three times with backoff (250 ms, 500 ms, 1 s) before the action fails. That means your handler can run more than once for the same event. The status bot is harmless to repeat, but a bot that creates tickets should de-duplicate, for example on the message id.

Loops. A bot that replies to messages will see its own replies, unless someone stops it. Slack leaves that to you (check bot_id and skip). Hasarak doesn’t send message.* events for messages a connector posted at all, so two bots can’t answer each other forever.

Version 2: the same bot with the SDK

Hasarak’s TypeScript SDK, @hasarak/sdk, wraps the protocol: defineConnector checks the signature (reading HASARAK_PUBLIC_KEY from env) before any handler runs, answers GET and manifest, gives you a typed API client already holding the token, and turns a thrown error into a failed action.

import { defineConnector, metadata } from "@hasarak/sdk";

const STATUS = /!status\s+(https?:\/\/\S+)/;

export default defineConnector<{ HASARAK_PUBLIC_KEY?: string }>({
  manifest: {
    name: "status",
    title: "Status",
    description: "Replies to !status <url> with the URL's HTTP status and latency",
    permissions: ["messages:read", "messages:write"],
  },
  install: () => ({ actions: [metadata({}, ["message.created"])] }),
  refresh: () => ({ actions: [metadata({}, ["message.created"])] }),

  async event({ api, message }) {
    const match = message?.text?.match(STATUS);
    if (!message || !match) return;
    const { error } = await api.POST("/v1/messages/{messageId}/replies", {
      params: { path: { messageId: message.parentId ?? message.id } },
      body: { text: await check(match[1]) },
    });
    if (error) throw new Error(`reply failed: ${error.error}`);
  },
});

check is the same function as in version 1. api is an openapi-fetch client generated from the spec, so the path, its parameters and the body are type-checked. The SDK isn’t published to npm yet, so for now version 1 is the one you can run as is; version 2 shows how the first-party connectors, like the GitHub connector, are written. That one finds pull request links in messages, replies with each PR’s title, state and size, and caps itself at three PRs per message.

Version 3: let AI agents call it too

A bot that only reacts to !status is useful to people. The same check is useful to an AI agent investigating a deploy, if the agent can reach it.

Hasarak’s workspace is also an MCP server, and a connector can add tools to it. Declare them in the tools map and the SDK sends them to Hasarak on every install and refresh:

export default defineConnector({
  // …manifest, install, event as above…
  tools: {
    "http.check": {
      description: "Requests a URL and returns its HTTP status and latency",
      input: { type: "object", required: ["url"], properties: { url: { type: "string" } } },
      output: { type: "string" },
      run: ({ input }) => {
        const url = String((input as { url?: unknown }).url ?? "");
        if (!/^https?:\/\//.test(url)) throw new Error("url must be http(s)");
        return check(url);
      },
    },
  },
});

An agent connected to the workspace’s MCP server can now write await tools.status.http.check({ url: "https://example.com/health" }) in the program it runs (the server uses “code mode”, covered in the MCP guide). The same tool is offered to @ai in the chat when its answer comes from ChatGPT. Note that input isn’t validated against the schema for you, so check it, as above.

Admins choose which roles may use a connector’s tools on its row in Settings › Connectors, and guests never get connector tools, because a tool call runs with the connector’s own credentials, not the caller’s.

Deploying and installing

  1. Deploy the endpoint anywhere that serves HTTPS, and give it HASARAK_PUBLIC_KEY: the public half of the key Hasarak signs actions with, as SPKI PEM or bare base64.
  2. Install it by URL: in Settings › Connectors, press Install from URL, paste the endpoint and press Install. Hasarak fetches the manifest, shows the permissions it asks for, and sends install.
  3. Ask for more later: an answer to install or refresh can include a permissions action. New permissions wait on the connector’s row as needs approval until an admin approves them.

The step-by-step version is in the docs: Enable a custom connector.

A checklist for any webhook bot

Whatever platform you’re building for:

  • Verify signatures on the raw body, check the timestamp, and reject before parsing anything else.
  • Answer fast. Slack and Discord give you three seconds. Do slow work after acknowledging (Slack, Discord) or keep it short and idempotent (Hasarak retries failures).
  • Make handlers idempotent. Every platform retries.
  • Ask for the narrowest permissions and add more when a feature needs them.
  • Ignore your own messages, unless the platform already does.
  • Log the action and outcome, not the body. Bodies contain message text and short-lived tokens.

Further reading

Team chat for people who live on the keyboard.

Channels, threads, an inbox and Markdown pages, with an MCP server and open connectors.

Start free