Chatsy

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.

Asad Ali
Founder & CEO
March 30, 2026
17 min read
Share:
Featured image for article: Building Custom Chatbot Integrations with Webhooks & APIs - Tutorials guide by Asad Ali

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.

typescript
import 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.

typescript
import { 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.

typescript
interface 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.

typescript
async 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

bash
mkdir 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

bash
npx 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.

typescript
const 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).

typescript
class 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.

typescript
import 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

typescript
interface 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:

typescript
async 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:

typescript
import 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

MetricWhat It Tells YouAlert Threshold
Webhook receive rateVolume of events from the chatbot platformSudden drop (platform issue) or spike (abuse)
Signature validation failure ratePotential security issues or misconfiguration>1% of requests
Processing success rateHow reliably you handle events<95%
External API latencyHow fast the helpdesk/CRM respondsP95 > 5s
Retry rateHow often the first attempt fails>10% indicates upstream issues
DLQ depthEvents that failed all retries>0 requires investigation

Health Check Endpoint

Expose a health check that verifies connectivity to external dependencies:

typescript
app.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

  1. Webhooks push events in real time from your chatbot to external systems. Always secure them with HMAC signatures and validate before processing.
  2. Acknowledge fast, process async. Return 200 immediately and handle the work in the background to avoid timeout issues.
  3. Build retry logic from day one. Exponential backoff with jitter and a dead-letter queue for exhausted retries saves you from silent data loss.
  4. Idempotency is not optional. Webhook platforms retry, and your handler must produce the same result when processing the same event twice.
  5. 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.


#webhooks#API#integration#tutorial#developer#chatbot
Related

Related Articles

Ready to try Chatsy?

Build your own AI customer support agent in minutes — no code required.

Start Free Trial