Build a webhook bot server
Implement the request structure Tabple sends, signature verification, the 8-second synchronous reply, and callback-based asynchronous replies.
What you need
- A server on a public address reachable over HTTPS — localhost and private IPs are blocked.
- The secret you created when registering the bot — use the same value for signature verification.
- A handler that answers within 8 seconds, or switches to the asynchronous callback flow when it needs longer — going past 8 seconds is a timeout failure, not an automatic switch.
Understanding the request
The request you receive
When a mention happens, Tabple POSTs a JSON body to the registered webhook URL. text is the user message without the mention, and channel.kind is project for a project channel or dm for a DM (in which case project_id is null).
{
"event": "message.created",
"invocation_id": 482,
"bot": { "id": 3, "name": "assistant" },
"channel": { "id": 17, "kind": "project", "project_id": 12 },
"invoker": { "id": 7, "name": "Geun" },
"parent_message_id": 90412,
"text": "이번 주 회의록 요약해줘",
"callback_url": "https://tabple.com/api/bots/3/invocations/482/callback",
"callback_token": "f3a9c1…(48자 hex)",
"timestamp": "2026-06-10T02:14:33.120Z"
}Request headers
The values you need for signature verification come along as headers.
Content-Type: application/json
X-Tabple-Bot-ID: 3
X-Tabple-Timestamp: 1781057673
X-Tabple-Signature: v1=2f8a41…(hex 64자)
User-Agent: Tabple-Bot/1.0X-Tabple-Timestamp is in Unix seconds. Reject any request more than 5 minutes off your server clock — it may be a replay attack.Signature verification (HMAC-SHA256)
How the signature works
The signed string is <X-Tabple-Timestamp value>.<raw request body>. Tabple computes HMAC-SHA256 over it with the secret you registered, prefixes the hex digest with v1=, and sends it in the X-Tabple-Signature header.
Node.js verification example
The whole flow with Express. Use a raw parser to keep the original body intact.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.TABPLE_BOT_SECRET;
const app = express();
function isValidSignature(rawBody, ts, signature) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "v1=" + crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature ?? "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const ts = req.header("X-Tabple-Timestamp");
const signature = req.header("X-Tabple-Signature");
if (!isValidSignature(rawBody, ts, signature)) {
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse(rawBody);
res.json({ reply: `${event.invoker.name}님, "${event.text}" 잘 받았어요!` });
});
app.listen(3000);Synchronous reply — answer within 8 seconds
200 + reply
If you can answer within 8 seconds, return HTTP 200 with a reply string. It is posted as the bot's message as is.
{
"reply": "이번 주 회의록 요약입니다. …",
"attachments": [
{ "name": "summary.png", "url": "https://cdn.example.com/summary.png",
"mime": "image/png", "size": 48211, "kind": "image" }
]
}Asynchronous reply — callback PUT
Acknowledge with 202 first
For slow work such as an LLM call, return HTTP 202 (or 200 with { "pending": true }) right away, then send the result to the callback_url that came with the request once the work finishes.
Send the callback PUT
Send a PUT request to callback_url. For authentication, put the callback_token that came with the request into the X-Tabple-Callback-Token header as is; the body uses the same format as a synchronous reply.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!isValidSignature(rawBody, req.header("X-Tabple-Timestamp"),
req.header("X-Tabple-Signature"))) {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
res.status(202).end();
runLongTask(event.text).then(async (answer) => {
if (!event.callback_url) return;
await fetch(event.callback_url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-Tabple-Callback-Token": event.callback_token,
},
body: JSON.stringify({ reply: answer }),
});
});
});Callback lifetime
A callback token is valid for 5 minutes from the call, and for one use only. Sending after that, or with a token already used, returns 404.
callback_url and callback_token can be null depending on server configuration. If they are null, use synchronous replies only.Reply limits and troubleshooting
replyhas the same 4,000-character limit as a regular message, and anything over that is truncated.attachmentsholds up to 8 entries andurlmust be HTTPS.kindisimageorfile; any other value is treated asfile.- If the bot server returns 4xx/5xx or takes longer than 8 seconds, a bot-response-failed message is posted to the chat — the beginning of the error body is shown with it, so use it for debugging.
- A 200 response without
replyis treated as an error. Return a short note even when there is nothing to answer. - For call limits per bot, caller, and project, see the Connect a chat bot guide.