Building Custom Chatbot Integrations with Webhooks & APIs
A developer guide to connecting AI chatbots with external systems using webhooks, API integrations, and event-driven patterns --- with TypeScript code examples and production best practices.
An AI chatbot that can answer questions is useful. A chatbot that can answer questions, create support tickets, look up orders, update CRM records, and trigger workflows is transformative. The bridge between a standalone chatbot and an integrated one is webhooks and APIs.
This guide walks through the fundamentals of webhook-based integrations, common patterns for connecting chatbots to external systems, and a complete step-by-step tutorial for building a webhook that creates support tickets when the chatbot cannot resolve an issue.
TL;DR:
- Webhooks are HTTP callbacks that push events from your chatbot to external systems in real time. Secure them with HMAC signatures and validate every incoming payload.
- Common integration patterns include CRM sync (log conversations), ticket creation (escalate unresolved issues), order lookup (pull real-time data), and knowledge base updates (keep content fresh).
- Build integrations with retry logic and exponential backoff from day one. Webhook delivery fails more often than you expect, and silent failures erode trust.
Webhook Fundamentals
A webhook is an HTTP POST request sent from one system to another when an event occurs. Unlike polling (repeatedly asking "anything new?"), webhooks push events as they happen.
┌──────────────┐ Event occurs ┌──────────────────┐
│ Chatbot │ ──── HTTP POST ────▶ │ Your Webhook │
│ Platform │ │ Endpoint │
└──────────────┘ └────────┬─────────┘
│
Process event
│
┌────────▼─────────┐
│ External System │
│ (CRM, Helpdesk, │
│ Order System) │
└──────────────────┘
Request and Response Pattern
The chatbot platform sends a POST request with a JSON payload describing the event. Your endpoint processes it and returns a 2xx status code to acknowledge receipt.
typescript// What the chatbot platform sends to your webhook { "event": "conversation.escalated", "timestamp": "2026-03-30T14:22:00Z", "data": { "conversationId": "conv_abc123", "customerId": "cust_456", "customerEmail": "user@example.com", "messages": [ { "role": "user", "content": "My order hasn't arrived and it's been 2 weeks" }, { "role": "assistant", "content": "I apologize for the delay. Let me look into this..." }, { "role": "user", "content": "I want to speak to someone" } ], "metadata": { "agentConfidence": 0.32, "escalationReason": "customer_requested", "detectedIntent": "shipping_complaint" } } }
Your endpoint must respond quickly (under 5 seconds for most platforms). If processing takes longer, accept the webhook immediately and process asynchronously:
typescript// Good: acknowledge immediately, process async app.post("/webhooks/chatbot", async (req, res) => { // Validate signature first (see next section) if (!verifySignature(req)) { return res.status(401).json({ error: "Invalid signature" }); } // Acknowledge receipt res.status(200).json({ received: true }); // Process asynchronously processWebhookEvent(req.body).catch((err) => { console.error("Webhook processing failed:", err); }); });
Securing Webhooks with HMAC Signatures
Anyone who discovers your webhook URL can send fake events. HMAC signatures prevent this. The chatbot platform signs each payload with a shared secret, and your endpoint verifies the signature before processing.
typescriptimport crypto from "crypto"; import type { Request } from "express"; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; function verifySignature(req: Request): boolean { const signature = req.headers["x-webhook-signature"] as string; if (!signature) return false; const expectedSignature = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(JSON.stringify(req.body)) .digest("hex"); // Timing-safe comparison to prevent timing attacks return crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expectedSignature, "hex") ); }
Always use timingSafeEqual instead of === for signature comparison. A regular string comparison leaks information about how many characters match, which attackers can exploit.
Idempotency
Webhook platforms retry on failure, which means your endpoint may receive the same event multiple times. Design your handler to be idempotent --- processing the same event twice should produce the same result as processing it once.
typescriptimport { Redis } from "ioredis"; const redis = new Redis(process.env.REDIS_URL); async function processWebhookEvent(event: WebhookEvent): Promise<void> { const eventId = event.data.conversationId + ":" + event.timestamp; // Check if we already processed this event const alreadyProcessed = await redis.get(`webhook:processed:${eventId}`); if (alreadyProcessed) { console.log(`Skipping duplicate event: ${eventId}`); return; } // Process the event await handleEvent(event); // Mark as processed with a 24h TTL await redis.set(`webhook:processed:${eventId}`, "1", "EX", 86400); }
Common Integration Patterns
1. CRM Sync
Log every chatbot conversation to your CRM so sales and support teams have full context.
typescriptinterface CRMContact { email: string; properties: Record<string, string>; } async function syncConversationToCRM(event: ConversationEndedEvent): Promise<void> { const { customerEmail, messages, metadata } = event.data; // Create or update the contact in your CRM const contact = await crmClient.contacts.createOrUpdate({ email: customerEmail, properties: { last_chatbot_interaction: new Date().toISOString(), chatbot_resolution_status: metadata.resolved ? "resolved" : "unresolved", chatbot_detected_intent: metadata.detectedIntent, }, }); // Log the conversation as an activity/note await crmClient.activities.create({ contactId: contact.id, type: "chatbot_conversation", body: summarizeConversation(messages), metadata: { conversationId: event.data.conversationId, messageCount: messages.length, duration: metadata.durationSeconds, }, }); }
2. Ticket Creation
When the chatbot cannot resolve an issue, automatically create a support ticket with full context.
typescriptasync function createSupportTicket(event: EscalationEvent): Promise<string> { const { customerId, customerEmail, messages, metadata } = event.data; const ticket = await helpdeskClient.tickets.create({ subject: `Chatbot escalation: ${metadata.detectedIntent}`, requester: { email: customerEmail }, priority: determinePriority(metadata), tags: ["chatbot-escalation", metadata.detectedIntent], description: formatTicketDescription(messages, metadata), customFields: { chatbot_conversation_id: event.data.conversationId, escalation_reason: metadata.escalationReason, ai_confidence_score: metadata.agentConfidence, }, }); return ticket.id; } function determinePriority(metadata: EscalationMetadata): "low" | "normal" | "high" | "urgent" { if (metadata.escalationReason === "customer_frustrated") return "high"; if (metadata.agentConfidence < 0.2) return "high"; if (metadata.detectedIntent.includes("billing")) return "normal"; return "normal"; } function formatTicketDescription( messages: Message[], metadata: EscalationMetadata ): string { const transcript = messages .map((m) => `**${m.role === "user" ? "Customer" : "AI"}:** ${m.content}`) .join("\n\n"); return `## Chatbot Escalation **Reason:** ${metadata.escalationReason} **AI Confidence:** ${(metadata.agentConfidence * 100).toFixed(0)}% **Detected Intent:** ${metadata.detectedIntent} ## Conversation Transcript ${transcript}`; }
3. Order Lookup
The chatbot calls your API to fetch real-time order data during the conversation.
typescript// This is an outbound API call from the chatbot, not a webhook // Registered as a "tool" the chatbot can invoke async function lookupOrder(orderId: string, customerEmail: string): Promise<OrderInfo> { const response = await fetch(`${ORDER_API_URL}/orders/${orderId}`, { headers: { Authorization: `Bearer ${ORDER_API_TOKEN}`, "Content-Type": "application/json", }, }); if (!response.ok) { throw new Error(`Order lookup failed: ${response.status}`); } const order = await response.json(); // Verify the order belongs to this customer if (order.customerEmail !== customerEmail) { throw new Error("Order does not belong to this customer"); } return { orderId: order.id, status: order.status, trackingNumber: order.trackingNumber, estimatedDelivery: order.estimatedDelivery, items: order.items.map((i: OrderItem) => ({ name: i.productName, quantity: i.quantity, })), }; }
4. Knowledge Base Updates
When your documentation changes, trigger a webhook to re-index the updated content in your chatbot's knowledge base.
typescript// Webhook from your CMS when a help article is published or updated app.post("/webhooks/cms-update", async (req, res) => { if (!verifySignature(req)) { return res.status(401).json({ error: "Invalid signature" }); } res.status(200).json({ received: true }); const { articleId, action, content, title, url } = req.body; switch (action) { case "published": case "updated": await chatbotKB.upsertDocument({ externalId: articleId, title, content, sourceUrl: url, lastUpdated: new Date().toISOString(), }); break; case "unpublished": await chatbotKB.deleteDocument({ externalId: articleId }); break; } });
Step-by-Step Tutorial: Escalation Ticket Webhook
Let's build a complete webhook endpoint that creates a support ticket in an external system when the chatbot escalates a conversation. We will use Express, TypeScript, and a generic helpdesk API.
Project Setup
bashmkdir chatbot-webhook && cd chatbot-webhook npm init -y npm install express dotenv npm install -D typescript @types/express @types/node tsx npx tsc --init
The Complete Webhook Server
typescript// src/server.ts import express from "express"; import crypto from "crypto"; import dotenv from "dotenv"; dotenv.config(); const app = express(); app.use(express.json()); // --- Configuration --- const PORT = process.env.PORT || 3000; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; const HELPDESK_API_URL = process.env.HELPDESK_API_URL!; const HELPDESK_API_KEY = process.env.HELPDESK_API_KEY!; // --- Types --- interface WebhookPayload { event: string; timestamp: string; data: { conversationId: string; customerId: string; customerEmail: string; customerName?: string; messages: Array<{ role: "user" | "assistant"; content: string }>; metadata: { agentConfidence: number; escalationReason: string; detectedIntent: string; sessionDurationSeconds: number; }; }; } interface TicketResponse { id: string; url: string; status: string; } // --- Signature Verification --- function verifyWebhookSignature( payload: string, signature: string | undefined ): boolean { if (!signature) return false; const expected = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(payload) .digest("hex"); try { return crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex") ); } catch { return false; } } // --- Helpdesk API Client --- async function createTicket(payload: WebhookPayload): Promise<TicketResponse> { const { data } = payload; const ticketBody = { subject: `[Chatbot Escalation] ${data.metadata.detectedIntent}`, requester: { email: data.customerEmail, name: data.customerName || "Customer", }, priority: data.metadata.agentConfidence < 0.3 ? "high" : "normal", tags: ["chatbot-escalation", data.metadata.detectedIntent], description: buildTicketDescription(data), custom_fields: { conversation_id: data.conversationId, escalation_reason: data.metadata.escalationReason, ai_confidence: data.metadata.agentConfidence, session_duration: data.metadata.sessionDurationSeconds, }, }; const response = await fetch(`${HELPDESK_API_URL}/tickets`, { method: "POST", headers: { Authorization: `Bearer ${HELPDESK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(ticketBody), }); if (!response.ok) { const errorBody = await response.text(); throw new Error( `Helpdesk API error: ${response.status} - ${errorBody}` ); } return response.json() as Promise<TicketResponse>; } function buildTicketDescription( data: WebhookPayload["data"] ): string { const transcript = data.messages .map((m) => { const speaker = m.role === "user" ? "Customer" : "AI Agent"; return `**${speaker}:** ${m.content}`; }) .join("\n\n"); return `## Escalation Details | Field | Value | |-------|-------| | Conversation ID | ${data.conversationId} | | Escalation Reason | ${data.metadata.escalationReason} | | AI Confidence | ${(data.metadata.agentConfidence * 100).toFixed(0)}% | | Detected Intent | ${data.metadata.detectedIntent} | | Session Duration | ${data.metadata.sessionDurationSeconds}s | ## Conversation Transcript ${transcript}`; } // --- Webhook Endpoint --- app.post("/webhooks/escalation", (req, res) => { // Step 1: Verify signature const rawBody = JSON.stringify(req.body); const signature = req.headers["x-webhook-signature"] as string; if (!verifyWebhookSignature(rawBody, signature)) { console.warn("Invalid webhook signature received"); return res.status(401).json({ error: "Invalid signature" }); } const payload = req.body as WebhookPayload; // Step 2: Validate event type if (payload.event !== "conversation.escalated") { return res.status(200).json({ skipped: true, reason: "Unhandled event type" }); } // Step 3: Acknowledge immediately res.status(200).json({ received: true, conversationId: payload.data.conversationId }); // Step 4: Process asynchronously createTicket(payload) .then((ticket) => { console.log( `Ticket created: ${ticket.id} for conversation ${payload.data.conversationId}` ); }) .catch((err) => { console.error( `Failed to create ticket for ${payload.data.conversationId}:`, err ); // In production: push to a dead-letter queue for retry }); }); // Health check app.get("/health", (_, res) => { res.status(200).json({ status: "ok" }); }); app.listen(PORT, () => { console.log(`Webhook server listening on port ${PORT}`); });
Environment Variables
bash# .env PORT=3000 WEBHOOK_SECRET=your-shared-secret-from-chatbot-platform HELPDESK_API_URL=https://api.yourhelpdesk.com/v2 HELPDESK_API_KEY=your-helpdesk-api-key
Running It
bashnpx tsx src/server.ts
Authentication Patterns
Different external systems require different authentication mechanisms. Here are the three most common patterns:
API Keys
The simplest approach. Include a static key in the request header.
typescriptconst response = await fetch(apiUrl, { headers: { "X-API-Key": process.env.EXTERNAL_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify(payload), });
Pros: Simple, no token refresh logic. Cons: Key rotation requires redeployment. No scoping or expiry.
OAuth 2.0 Client Credentials
For machine-to-machine communication where your server authenticates as itself (not on behalf of a user).
typescriptclass OAuth2Client { private accessToken: string | null = null; private tokenExpiry: number = 0; constructor( private clientId: string, private clientSecret: string, private tokenUrl: string ) {} async getAccessToken(): Promise<string> { // Return cached token if still valid if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) { return this.accessToken; } const response = await fetch(this.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this.clientSecret, scope: "tickets:write contacts:read", }), }); if (!response.ok) { throw new Error(`Token request failed: ${response.status}`); } const data = await response.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + data.expires_in * 1000; return this.accessToken; } async authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> { const token = await this.getAccessToken(); return fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}`, }, }); } }
JWT (JSON Web Tokens)
Generate short-lived tokens signed with your private key. The external system verifies the signature with your public key.
typescriptimport jwt from "jsonwebtoken"; function generateServiceToken(): string { return jwt.sign( { iss: "chatbot-webhook-service", sub: "webhook-integration", aud: "helpdesk-api", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, // 5-minute expiry }, process.env.JWT_PRIVATE_KEY!, { algorithm: "RS256" } ); }
Error Handling and Retry Logic
Webhook delivery to external APIs fails regularly --- network timeouts, rate limits, transient server errors. Build retry logic from the start.
Exponential Backoff with Jitter
typescriptinterface RetryConfig { maxRetries: number; baseDelayMs: number; maxDelayMs: number; } async function withRetry<T>( fn: () => Promise<T>, config: RetryConfig = { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000 } ): Promise<T> { let lastError: Error | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { return await fn(); } catch (err) { lastError = err as Error; if (attempt === config.maxRetries) break; // Don't retry client errors (4xx except 429) if (err instanceof ApiError && err.status >= 400 && err.status < 500 && err.status !== 429) { throw err; } // Exponential backoff with jitter const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt); const jitter = Math.random() * config.baseDelayMs; const delay = Math.min(exponentialDelay + jitter, config.maxDelayMs); console.warn( `Attempt ${attempt + 1} failed, retrying in ${Math.round(delay)}ms:`, (err as Error).message ); await sleep(delay); } } throw lastError; } function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } // Usage const ticket = await withRetry(() => createTicket(payload), { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000, });
Dead-Letter Queue
For events that fail after all retries, push them to a dead-letter queue for manual review or later processing:
typescriptasync function processWebhookWithDLQ(payload: WebhookPayload): Promise<void> { try { await withRetry(() => createTicket(payload)); } catch (err) { console.error("All retries exhausted. Pushing to DLQ:", err); await deadLetterQueue.push({ payload, error: (err as Error).message, failedAt: new Date().toISOString(), retryCount: 3, }); // Alert the team await alerting.notify({ channel: "webhook-failures", message: `Webhook processing failed for conversation ${payload.data.conversationId}`, severity: "warning", }); } }
Testing Webhooks Locally
ngrok
Expose your local server to the internet so the chatbot platform can reach it:
bash# Terminal 1: Run your server npx tsx src/server.ts # Terminal 2: Expose port 3000 ngrok http 3000
ngrok gives you a public URL like https://a1b2c3d4.ngrok.io. Register this as your webhook URL in the chatbot platform settings, appending your path: https://a1b2c3d4.ngrok.io/webhooks/escalation.
Manual Testing with curl
Send a test event to your local endpoint:
bash# Generate a valid signature SECRET="your-shared-secret" PAYLOAD='{"event":"conversation.escalated","timestamp":"2026-03-30T14:22:00Z","data":{"conversationId":"conv_test123","customerId":"cust_456","customerEmail":"test@example.com","messages":[{"role":"user","content":"I need help"},{"role":"assistant","content":"Let me connect you with a human agent."}],"metadata":{"agentConfidence":0.25,"escalationReason":"low_confidence","detectedIntent":"general_inquiry","sessionDurationSeconds":45}}}' SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') curl -X POST http://localhost:3000/webhooks/escalation \ -H "Content-Type: application/json" \ -H "x-webhook-signature: $SIGNATURE" \ -d "$PAYLOAD"
webhook.site
For inspecting what your chatbot platform is actually sending, use webhook.site. It gives you a temporary URL that logs all incoming requests --- headers, body, timing. Useful for debugging payload format issues before writing any code.
Monitoring and Debugging in Production
Structured Logging
Log every webhook event with enough context to debug failures:
typescriptimport pino from "pino"; const logger = pino({ level: "info" }); app.post("/webhooks/escalation", (req, res) => { const conversationId = req.body?.data?.conversationId || "unknown"; const childLogger = logger.child({ conversationId, event: req.body?.event }); if (!verifyWebhookSignature(JSON.stringify(req.body), req.headers["x-webhook-signature"] as string)) { childLogger.warn("Invalid webhook signature"); return res.status(401).json({ error: "Invalid signature" }); } childLogger.info("Webhook received"); res.status(200).json({ received: true }); createTicket(req.body as WebhookPayload) .then((ticket) => { childLogger.info({ ticketId: ticket.id }, "Ticket created successfully"); }) .catch((err) => { childLogger.error({ err }, "Ticket creation failed"); }); });
Key Metrics to Track
| Metric | What It Tells You | Alert Threshold |
|---|---|---|
| Webhook receive rate | Volume of events from the chatbot platform | Sudden drop (platform issue) or spike (abuse) |
| Signature validation failure rate | Potential security issues or misconfiguration | >1% of requests |
| Processing success rate | How reliably you handle events | <95% |
| External API latency | How fast the helpdesk/CRM responds | P95 > 5s |
| Retry rate | How often the first attempt fails | >10% indicates upstream issues |
| DLQ depth | Events that failed all retries | >0 requires investigation |
Health Check Endpoint
Expose a health check that verifies connectivity to external dependencies:
typescriptapp.get("/health", async (_, res) => { const checks: Record<string, "ok" | "error"> = {}; try { await fetch(`${HELPDESK_API_URL}/health`, { headers: { Authorization: `Bearer ${HELPDESK_API_KEY}` }, signal: AbortSignal.timeout(3000), }); checks.helpdesk = "ok"; } catch { checks.helpdesk = "error"; } try { await redis.ping(); checks.redis = "ok"; } catch { checks.redis = "error"; } const allHealthy = Object.values(checks).every((v) => v === "ok"); res.status(allHealthy ? 200 : 503).json({ status: allHealthy ? "ok" : "degraded", checks }); });
Key Takeaways
- Webhooks push events in real time from your chatbot to external systems. Always secure them with HMAC signatures and validate before processing.
- Acknowledge fast, process async. Return 200 immediately and handle the work in the background to avoid timeout issues.
- Build retry logic from day one. Exponential backoff with jitter and a dead-letter queue for exhausted retries saves you from silent data loss.
- Idempotency is not optional. Webhook platforms retry, and your handler must produce the same result when processing the same event twice.
- Monitor everything. Track receive rate, processing success, external API latency, and DLQ depth. Silent failures compound quickly.
Frequently Asked Questions
What is the difference between a webhook and an API?
An API is a set of endpoints you call when you need data (pull model). A webhook is an HTTP callback that a remote system sends to you when an event occurs (push model). In chatbot integrations, you use APIs to fetch data (e.g., looking up an order) and webhooks to react to events (e.g., creating a ticket when a conversation is escalated). Most integrations use both: webhooks trigger the workflow, and API calls within that workflow interact with external systems.
How do I secure my webhook endpoint?
Use HMAC signatures. The chatbot platform signs each payload with a shared secret using HMAC-SHA256. Your endpoint recomputes the signature from the raw request body and the same secret, then compares them using a timing-safe comparison function. Reject any request where the signature does not match. Additionally, use HTTPS for transport encryption, restrict your endpoint to expected IP ranges if your platform publishes them, and validate the payload schema before processing.
What happens if my webhook endpoint is down?
Most chatbot platforms implement automatic retries with exponential backoff. If your endpoint returns a non-2xx status code or times out, the platform retries --- typically 3--5 times over several hours. Events that exhaust all retries are usually logged in the platform's webhook delivery dashboard. On your side, design for idempotency so that when the endpoint recovers and retries arrive, duplicate processing does not create duplicate tickets or records.
How do I test webhooks during local development?
Use ngrok to expose your local server to the internet. Run your server on localhost, then run ngrok http 3000 to get a public URL. Register that URL as your webhook endpoint in the chatbot platform. For manual testing, use curl with a pre-computed HMAC signature to send test payloads directly. For inspecting the raw payloads your platform sends, use webhook.site to capture and display incoming requests.
How do I handle webhook events that require slow processing?
Accept the webhook immediately with a 200 response, then process the event asynchronously. For simple cases, use an async function call after responding. For production systems, push the event to a message queue (like SQS, Redis Streams, or BullMQ) and process it with a separate worker. This decouples receiving from processing, prevents timeouts, and lets you scale workers independently based on queue depth.