Webhook 봇 서버 만들기
Tabple가 보내는 요청의 구조와 서명 검증, 8초 동기 응답과 콜백 기반 비동기 응답을 구현합니다.
준비물
- HTTPS로 접근 가능한 공인 주소의 서버 — localhost·사설 IP는 차단됩니다.
- 봇 등록 시 만든 Secret — 서명 검증에 같은 값을 사용합니다.
- 8초 안에 응답하거나, 시간이 더 걸리면 비동기 콜백 방식으로 전환할 수 있는 핸들러.
요청 구조 이해하기
수신하는 요청
멘션이 발생하면 Tabple가 등록된 Webhook URL로 JSON 본문을 POST합니다. text가 멘션을 제외한 사용자 메시지이고, channel.kind는 프로젝트 채널이면 project, DM이면 dm(이때 project_id는 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"
}요청 헤더
서명 검증에 필요한 값이 헤더로 함께 전달됩니다.
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는 Unix 초 단위입니다. 서버 시각과 5분 넘게 차이 나는 요청은 재전송 공격일 수 있으니 거부하세요.서명 검증 (HMAC-SHA256)
서명 방식
서명 대상은 <X-Tabple-Timestamp 값>.<원본 요청 본문> 문자열이고, 등록한 Secret을 키로 HMAC-SHA256을 계산한 hex 값에 v1= 접두어를 붙여 X-Tabple-Signature 헤더로 보냅니다.
Node.js 검증 예시
Express 기준 전체 흐름입니다. 원본 본문을 보존하기 위해 raw 파서를 사용합니다.
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);동기 응답 — 8초 안에 답하기
200 + reply
8초 안에 답할 수 있으면 HTTP 200으로 reply 문자열을 돌려주면 됩니다. 그대로 봇 메시지로 게시됩니다.
{
"reply": "이번 주 회의록 요약입니다. …",
"attachments": [
{ "name": "summary.png", "url": "https://cdn.example.com/summary.png",
"mime": "image/png", "size": 48211, "kind": "image" }
]
}비동기 응답 — 콜백 PUT
202로 먼저 접수
LLM 호출처럼 오래 걸리는 작업은 HTTP 202(또는 200 + { "pending": true })를 즉시 돌려주고, 작업이 끝난 뒤 요청에 담겨 온 callback_url로 결과를 보냅니다.
콜백 PUT 보내기
callback_url에 PUT 요청을 보내면 됩니다. 인증은 요청에 담겨 온 callback_token을 X-Tabple-Callback-Token 헤더에 그대로 넣는 방식이고, 본문은 동기 응답과 같은 형식입니다.
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 }),
});
});
});콜백 유효 기간
콜백 토큰은 호출 시점부터 5분간, 1회만 유효합니다. 시간이 지나거나 이미 사용된 토큰으로 보내면 404가 반환됩니다.
callback_url과 callback_token은 서버 설정에 따라 null일 수 있습니다. null이면 동기 응답만 사용하세요.응답 제한과 트러블슈팅
reply는 일반 메시지와 같은 4,000자 제한이 적용되며 초과분은 잘립니다.attachments는 최대 8개,url은 HTTPS만 허용됩니다.kind는image또는file이고 그 외 값은file로 처리됩니다.- 봇 서버가 4xx/5xx를 반환하거나 8초를 넘기면 채팅에 '봇 응답 실패' 메시지가 게시됩니다 — 오류 본문 앞부분이 함께 표시되므로 디버깅에 활용하세요.
reply가 없는 200 응답은 오류로 처리됩니다. 답할 내용이 없어도 짧은 안내 문구를 돌려주세요.- 호출 한도(봇·호출자·프로젝트별)는 '채팅 봇 연결하기' 가이드를 참고하세요.