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.
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.
This guide synthesizes operational specifics from three categories of sources:
We avoided pure marketing claims and prioritized examples that ship in real codebases. Where we cite latency or accuracy numbers, the methodology, dataset, or test conditions are noted alongside. Last reviewed: April 2026.
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) │
└──────────────────┘
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); }); });
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.
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); }
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, }, }); }
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}`; }
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, })), }; }
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; } });
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.
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
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}`); });
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
bashnpx tsx src/server.ts
Different external systems require different authentication mechanisms. Here are the three most common patterns:
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.
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}`, }, }); } }
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" } ); }
Webhook delivery to external APIs fails regularly --- network timeouts, rate limits, transient server errors. Build retry logic from the start.
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, });
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", }); } }
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.
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"
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.
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"); }); });
| 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 |
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 }); });
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.
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.
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.
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.
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.
A complete guide to adding live chat to your website. From choosing a platform to installing the widget, customizing it, training AI, and going live: with platform-specific instructions for Shopify, WordPress, Webflow, and custom sites.