How to Build a Messaging Bot with Unified (Slack, Microsoft Teams, Discord)
February 10, 2026
Building bots on messaging platforms is complex.
Most bots break in production for the same reason:
- They poll for messages instead of reacting to real-time events.
- They assume every platform behaves the same.
- They treat chat systems like databases instead of live systems.
Unified's Messaging API is designed for the model bots actually need.
It lets you build event-driven messaging bots that react to live activity in Slack, Microsoft Teams, and Discord — without polling loops, without storing message history, and without maintaining separate implementations per provider.
This guide shows the production-safe way to build a messaging bot using Unified.
It reflects how bots need to be built today:
- driven by real-time events
- aware of provider differences
- and safe to operate at scale
Overview
You'll build a production-ready messaging bot that:
- Authorizes a user's Slack, Microsoft Teams, or Discord account
- Subscribes to real-time activity using:
- Messaging Events (mentions, reactions, button clicks — where supported)
- or Message Created webhooks
- Receives webhook payloads and validates them using
sig256 - Applies your bot logic to each incoming event or message
- Sends a reply — either in-thread or directly in the channel
This flow keeps the bot event-driven, provider-aware, and safe to run in production.
Before you begin
You need:
- A Unified workspace API key
- Your workspace secret (for webhook verification)
- A messaging
connection_idfrom an authorized account - A public webhook URL (ngrok, Cloudflare Tunnel, etc.)
- Node.js 18+ (examples use TypeScript + Express)
Environment variables used below:
UNIFIED_API_KEY=...
UNIFIED_WORKSPACE_SECRET=...
CONNECTION_ID=...
PUBLIC_WEBHOOK_URL=https://your-domain.com/webhook
Provider capability matrix
X means supported, blank means not.
Inbound webhook availability
| Webhook event | Teams (bot) | Slack | Slack (bot) |
|---|---|---|---|
messaging_event_created | X | X | |
messaging_event_updated | X | ||
messaging_channel_created | X | X | |
messaging_channel_updated | X | X | |
messaging_channel_deleted | X | X |
Implications
- If you need
APP_MENTION,BUTTON_CLICK, reactions, or other interaction types, you must use Messaging Events, which are available only for:- Slack (bot)
- Microsoft Teams (bot)
- Discord does not support Messaging Events. Use message-created webhooks instead.
Threaded replies
Threading is standardized across supported messaging integrations using a single field:
parent_id
All messaging providers supported by Unified use parent_id to denote the immediate predecessor message in a thread.
Messaging Events model
Messaging Events include an explicit type enum:
MESSAGE_RECEIVEDREACTION_ADDEDREACTION_REMOVEDBUTTON_CLICKAPP_MENTIONCHANNEL_JOINEDCHANNEL_LEFTCHANNEL_CREATEDCHANNEL_DELETEDUSER_CREATEDUSER_DELETEDUSER_UPDATED
Event payloads may include:
event.typeevent.message(includingevent.message.id)event.channelevent.userevent.buttonevent.is_replacing_original(Slack (bot) context)
Reply mechanics (threaded replies)
When your bot receives a MessagingEvent or a Message Created webhook, replying is done by creating a new message.
There is no separate 'reply' endpoint. A reply is simply a message that references a parent message.
Step 1 — Extract the parent message ID
From a Messaging Event payload:
event.message.id
From a Message Created webhook payload:
message.id
That ID represents the message you are replying to.
Step 2 — Create the reply
All providers use parent_id for threading.
const parentMessageId = message.id;
const channels = message.channels ?? [];
await fetch(`https://api.unified.to/messaging/${connectionId}/message`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "Got it.",
channels,
parent_id: parentMessageId,
}),
});
Important:
- Use
channels(the normalized field). channel_idandchannel_idsare deprecated in the unified model.- Threading is controlled by remembering the parent message and setting
parent_id.
Updating vs replying (when an event arrives)
When your webhook receives a MessagingEvent, you must decide between two actions:
1. Post a new message (default behavior)
Create a new message using:
POST /messaging/{connection_id}/message
With:
messagechannelsparent_id
This is how bots:
- respond to mentions
- acknowledge commands
- follow up after reactions
- continue threaded conversations
This behavior works across all supported messaging integrations.
2. Replace the original interactive message (Slack (bot) only)
Slack (bot) integrations support updating interactive messages.
When handling events like BUTTON_CLICK, Slack (bot) supports writing:
messaging_event_messagemessaging_event_is_replacing_original
This allows you to overwrite the original message instead of posting a new one.
Other providers require posting a new message instead of replacing the original.
Thread traversal behavior
Unified also provides:
message_thread_identifier— identifies the root of a thread (read-only)has_children— indicates whether a message has replies
You do not need to set message_thread_identifier. It is informational.
Threads are defined by parent relationships, using parent_id.
Webhook security (required)
Unified webhook payloads include:
data(array)nonce(string)sig256(base64)
Signature verification
sig256 is generated as:
base64( HMAC-SHA256( workspace_secret, serialized(data) + nonce ) )
Rules that matter in practice:
- Verify only
data + nonce - Do not add whitespace
- Do not reorder fields
- Use a timing-safe comparison
The legacy sig (HMAC-SHA1) field is deprecated and should not be used.
Step 1: Create webhook subscriptions
Option A — Messaging Events (Slack (bot), Teams (bot))
await fetch("https://api.unified.to/unified/webhook", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
connection_id: process.env.CONNECTION_ID,
hook_url: process.env.PUBLIC_WEBHOOK_URL + "/events",
object_type: "messaging_event",
event: "created",
}),
});
Option B — Message created (Slack, Teams, Discord)
await fetch("https://api.unified.to/unified/webhook", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
connection_id: process.env.CONNECTION_ID,
hook_url: process.env.PUBLIC_WEBHOOK_URL + "/messages",
object_type: "messaging_message",
event: "created",
}),
});
Step 2: Build the webhook receiver (TypeScript + Express)
Install:
npm init -y
npm install express
npm install -D typescript ts-node @types/node @types/express
Create server.ts:
import express from "express";
import crypto from "crypto";
type UnifiedWebhookEnvelope<T> = {
data: T[];
webhook: unknown;
nonce: string;
sig256: string;
external_xref?: string;
type?: "INITIAL-PARTIAL" | "INITIAL-COMPLETE" | "VIRTUAL" | "NATIVE";
};
type MessagingEvent = {
type?:
| "MESSAGE_RECEIVED"
| "REACTION_ADDED"
| "REACTION_REMOVED"
| "BUTTON_CLICK"
| "APP_MENTION"
| "CHANNEL_JOINED"
| "CHANNEL_LEFT"
| "CHANNEL_CREATED"
| "CHANNEL_DELETED"
| "USER_CREATED"
| "USER_DELETED"
| "USER_UPDATED";
message?: {
id?: string;
message?: string;
channels?: { id?: string; name?: string }[];
};
button?: { id?: string; text?: string; icon?: string };
};
type MessagingMessage = {
id?: string;
message?: string;
channels?: { id?: string; name?: string }[];
};
const app = express();
app.use(express.json());
function timingSafeEq(a: string, b: string) {
const ba = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
function verifySig256(envelope: UnifiedWebhookEnvelope<any>, workspaceSecret: string) {
if (!envelope?.sig256 || !envelope?.nonce || envelope?.data == null) return false;
// Signature covers only `data + nonce`
const serializedData = JSON.stringify(envelope.data);
const nonce = String(envelope.nonce);
const computed = crypto
.createHmac("sha256", workspaceSecret)
.update(serializedData, "utf8")
.update(nonce, "utf8")
.digest("base64");
return timingSafeEq(computed, envelope.sig256);
}
async function unifiedCreateMessage(connectionId: string, body: Record<string, any>) {
const res = await fetch(`https://api.unified.to/messaging/${connectionId}/message`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Create message failed: ${res.statusText}`);
}
return res.json();
}
function buildReply(parentMessageId: string, channels: any[], text: string) {
return {
message: text,
channels,
parent_id: parentMessageId,
};
}
// Messaging Events handler
app.post("/events", async (req, res) => {
const connectionId = process.env.CONNECTION_ID!;
const secret = process.env.UNIFIED_WORKSPACE_SECRET!;
const envelope = req.body as UnifiedWebhookEnvelope<MessagingEvent>;
if (!verifySig256(envelope, secret)) return res.status(401).send("Invalid signature");
for (const event of envelope.data ?? []) {
if (!event.message?.id) continue;
if (event.type === "MESSAGE_RECEIVED" || event.type === "APP_MENTION") {
await unifiedCreateMessage(
connectionId,
buildReply(event.message.id, event.message.channels ?? [], "Got it.")
);
}
}
res.send("ok");
});
// Message-created handler (Discord / fallback)
app.post("/messages", async (req, res) => {
const connectionId = process.env.CONNECTION_ID!;
const secret = process.env.UNIFIED_WORKSPACE_SECRET!;
const envelope = req.body as UnifiedWebhookEnvelope<MessagingMessage>;
if (!verifySig256(envelope, secret)) return res.status(401).send("Invalid signature");
for (const msg of envelope.data ?? []) {
if (!msg.id) continue;
await unifiedCreateMessage(
connectionId,
buildReply(msg.id, msg.channels ?? [], "Got it.")
);
}
res.send("ok");
});
app.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});
Run:
npx ts-node server.ts
ngrok http 3000
Step 3: Test the bot
- Ensure webhooks point to
/eventsor/messages - Send a message or mention the bot
- Verify the webhook signature
- Confirm a threaded reply appears
What to avoid
- Polling list endpoints to detect new messages
- Treating Unified as a message store
- Using deprecated fields (
channel_id,channel_ids,root_message_id) - Using
sig(SHA-1)
Why teams build messaging bots on Unified
Teams build messaging bots on Unified to deliver real-time experiences inside the tools their customers already use.
That includes bots that:
- answer customer questions directly inside Slack or Teams
- escalate issues the moment a message, mention, or reaction occurs
- guide users through workflows without leaving the conversation
- trigger actions when something meaningful happens in chat
- support AI assistants that need live context, not stale message history
Unified handles the real-time delivery, provider differences, and security boundaries so teams can focus on what the bot does, not how each messaging platform behaves.
Whether you're building:
- a customer support bot that responds in real time
- a product assistant embedded in Slack or Teams
- automated workflows triggered by conversation activity
- or AI systems that need live messaging context
Unified gives you a single, real-time messaging surface that behaves predictably across platforms.