Webhook 봇 서버 만들기
Tabple가 보내는 요청의 구조와 서명 검증, 8초 동기 응답과 콜백 기반 비동기 응답을 구현합니다.
준비물
- HTTPS로 접근 가능한 공인 주소의 서버 — localhost·사설 IP는 차단됩니다.
- 봇 등록 시 만든 Secret — 서명 검증에 같은 값을 사용합니다.
- 8초 안에 응답하거나 더 걸릴 것 같으면 스스로 비동기 콜백으로 전환하는 핸들러 — 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 응답은 오류로 처리됩니다. 답할 내용이 없어도 짧은 안내 문구를 돌려주세요.- 호출 한도(봇·호출자·프로젝트별)는 '채팅 봇 연결하기' 가이드를 참고하세요.