Bot Worker Pattern
Building a webhook-driven bot on Cloudflare Workers
Architecture
A bot that receives webhooks (e.g., from Slack), processes them with an external AI API, and replies β all running on a Cloudflare Worker with KV for state.
Key design decisions:
- Immediate response: Return
200 OKbefore doing any work. Slack requires a response within 3 seconds. - Deferred processing: Use
ctx.waitUntil()for the actual processing that may take 10+ seconds. - KV for state: Conversation history and rate limits stored in KV with TTL-based expiration.
Wrangler Config
name = "my-bot"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"
Secrets set via dashboard or CLI:
npx wrangler secret put SLACK_BOT_TOKEN
npx wrangler secret put SLACK_SIGNING_SECRET
npx wrangler secret put API_KEY
Env Interface
interface Env {
KV: KVNamespace;
SLACK_BOT_TOKEN: string;
SLACK_SIGNING_SECRET: string;
API_KEY: string;
}
Worker Entry Point
The fetch handler must respond immediately, then process in the background:
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const body = await request.json().catch(() => null);
if (!body) {
return new Response("Bad Request", { status: 400 });
}
// Slack URL verification challenge
if (body.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
// Verify webhook signature
const rawBody = JSON.stringify(body);
const isValid = await verifySignature(request, rawBody, env);
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
// Respond immediately, process in background
ctx.waitUntil(handleEvent(body.event, env));
return new Response(null, { status: 200 });
},
};
π¨ Always respond immediately to webhooks
Slack, GitHub, and most webhook providers have strict timeout limits (typically 3 seconds). If your Worker doesnβt respond in time, the provider may retry or mark your endpoint as failed. Always return a response first, then use ctx.waitUntil() for processing.
Webhook Signature Verification
Verify the HMAC-SHA256 signature to ensure the request is authentic:
async function verifySignature(
request: Request,
rawBody: string,
env: Env,
): Promise<boolean> {
const timestamp = request.headers.get("x-slack-request-timestamp");
const signature = request.headers.get("x-slack-signature");
if (!timestamp || !signature) return false;
// Reject requests older than 5 minutes (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) return false;
const sigBasestring = `v0:${timestamp}:${rawBody}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(env.SLACK_SIGNING_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(sigBasestring),
);
const hexSig = "v0=" + [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return timingSafeEqual(hexSig, signature);
}
π‘ Use timing-safe comparison
Always use a constant-time comparison for signature verification. A naive === comparison can leak information via timing side-channels.
Timing-Safe String Comparison
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
Rate Limiting with KV
Per-user rate limiting using KV with TTL-based expiration:
const RATE_LIMIT = 30; // requests per window
const RATE_WINDOW = 86400; // 24 hours
async function checkRateLimit(
env: Env,
userId: string,
channelId: string,
): Promise<boolean> {
const key = `rate:${channelId}:${userId}`;
const current = await env.KV.get<number>(key, "json");
const count = current ?? 0;
if (count >= RATE_LIMIT) return false;
await env.KV.put(key, JSON.stringify(count + 1), {
expirationTtl: RATE_WINDOW,
});
return true;
}
β οΈ KV rate limiting is approximate
KV is eventually consistent. Under high concurrency, a few extra requests may slip through. This is acceptable for bot rate limiting β use Durable Objects if you need precise counting.
Conversation State in KV
Store multi-turn conversation history with automatic expiration:
const CONVERSATION_TTL = 86400; // 24 hours
interface ConversationHistory {
messages: Array<{ role: string; content: string }>;
}
async function loadHistory(
env: Env,
threadId: string,
): Promise<ConversationHistory> {
const key = `conv:${threadId}`;
const stored = await env.KV.get<ConversationHistory>(key, "json");
return stored ?? { messages: [] };
}
async function saveHistory(
env: Env,
threadId: string,
history: ConversationHistory,
): Promise<void> {
const key = `conv:${threadId}`;
await env.KV.put(key, JSON.stringify(history), {
expirationTtl: CONVERSATION_TTL,
});
}
Use "json" type parameter with KV.get() for automatic deserialization. The TTL ensures old conversations are cleaned up automatically.
AI Tool Use Loop
When using an AI API with tool calling, implement a bounded loop:
const MAX_TOOL_ROUNDS = 5;
async function callAI(
env: Env,
messages: Array<{ role: string; content: string }>,
): Promise<string> {
let round = 0;
while (round < MAX_TOOL_ROUNDS) {
const response = await fetch("https://api.example.com/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.API_KEY}`,
},
body: JSON.stringify({ messages, tools: TOOL_DEFINITIONS }),
});
const result = await response.json();
if (result.stop_reason !== "tool_use") {
return result.content;
}
// Execute tool calls and append results
for (const toolCall of result.tool_calls) {
const toolResult = await executeTool(toolCall, env);
messages.push(
{ role: "assistant", content: result.content },
{ role: "tool", content: JSON.stringify(toolResult) },
);
}
round++;
}
return "Reached maximum tool rounds.";
}
π‘ Always cap tool rounds
An unbounded tool loop could consume your Workerβs CPU time limit. Set a reasonable maximum (5-10 rounds) and return a fallback message when exceeded.
Project Structure
packages/my-bot/
βββ src/
β βββ index.ts # Worker entry point
β βββ webhook.ts # Signature verification
β βββ ai.ts # AI API integration + tool loop
β βββ tools.ts # Tool definitions and execution
β βββ types.ts # TypeScript interfaces
β βββ utils.ts # Helpers (encoding, timing-safe compare)
βββ wrangler.toml
βββ package.json
βββ tsconfig.json
Dependencies
Bot workers can be extremely lean β leverage Workers built-in APIs:
{
"devDependencies": {
"@cloudflare/workers-types": "^4.20250214.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"wrangler": "^4.0.0"
}
}
No external HTTP or crypto libraries needed. Workers provide native fetch() and crypto.subtle.
Deployment
npx wrangler@4 deploy
For monorepo setups with path-triggered CI, see Standalone Workers.