Module 4: Integration Patterns

Webhooks Inbound

Capsule overview

Inbound webhooks are how your system receives automatic notifications from external systems when something happens there. Slack notifies you of a message. Stripe notifies you of a payment. GitHub notifies you of a commit. Instead of your system constantly asking "did something happen?" (polling), the external system proactively tells you when something happens (push). For AI systems, inbound webhooks are the canonical entry pattern: practically every Slack bot, Discord integration, or connector with external apps uses inbound webhooks.

The concept sounds trivial — "it's just receiving an HTTP request" — but the correct implementation has several subtleties that differentiate a fragile webhook handler from a production-ready one: signature verification (protect against malicious requests), validation challenges (Slack and others require an initial handshake), timeout responses (you must respond fast even if the processing is long), idempotency (the same event can arrive duplicated), and replay handling. Each of these is typically learned with a bug in production if not planned from design.

In this capsule you learn the complete flow of inbound webhooks applied to AI: registration with the provider, identity verification (signing secrets), the fast-response + async-processing pattern, error handling, and common pitfalls. The canonical case will be the Slack Events API, but the principles apply to any webhook (Stripe, GitHub, Twilio).


The complete flow

1. Registration with the provider

Before receiving webhooks, the provider must know your endpoint URL:

Slack admin panel → Create app → Event Subscriptions
   → Request URL: https://your-api.com/webhooks/slack
   → Subscribe to events: app_mention, message.im, etc.

The provider typically requires:

  • A public and accessible URL (not localhost)
  • HTTPS (not plain HTTP)
  • The ability to respond to the verification challenge

2. Verification challenge (initial handshake)

When you register the URL, the provider sends a verification request to confirm that you control the URL:

# Slack sends:
POST /webhooks/slack
{
    "type": "url_verification",
    "challenge": "abc123def456",
    "token": "..."
}

# Your API must respond:
200 OK
{
    "challenge": "abc123def456"
}

Without a correct handshake, registration fails. Replaceable for Slack but similar for almost all providers.

3. Receive real events

After the handshake, the provider sends events:

POST /webhooks/slack
Headers:
  X-Slack-Signature: v0=abc123...
  X-Slack-Request-Timestamp: 1736000000

Body:
{
    "type": "event_callback",
    "event": {
        "type": "app_mention",
        "user": "U123",
        "text": "<@BOT> what is RAG?",
        "channel": "C456",
        "ts": "1736000000.000100"
    }
}

4. Verify signature

CRITICAL: verify that the request really comes from the provider, not from an attacker:

import hmac
import hashlib
import time

def verify_slack_signature(
    body: bytes, 
    timestamp: str, 
    signature: str,
    signing_secret: str
) -> bool:
    # Reject old requests (replay attack protection)
    if abs(time.time() - int(timestamp)) > 60 * 5:
        return False
    
    # Compute expected signature
    base_string = f"v0:{timestamp}:{body.decode('utf-8')}"
    expected = "v0=" + hmac.new(
        signing_secret.encode(),
        base_string.encode(),
        hashlib.sha256
    ).hexdigest()
    
    # Constant-time comparison (prevent timing attacks)
    return hmac.compare_digest(expected, signature)

Without this, anyone with your URL can trigger your system with fake events.

5. Respond fast

The provider typically requires a response within 3 seconds (Slack), 10 seconds (Stripe), etc. If you take longer, it considers the delivery failed, retries, and possibly disables your webhook after many failures.

That's why the typical AI pattern is: fast ACK + async processing (we go deeper in capsule 04).

@app.post("/webhooks/slack")
async def slack_webhook(request: Request):
    body = await request.body()
    
    # 1. Verify signature
    timestamp = request.headers.get("X-Slack-Request-Timestamp")
    signature = request.headers.get("X-Slack-Signature")
    if not verify_slack_signature(body, timestamp, signature, SLACK_SIGNING_SECRET):
        raise HTTPException(401, "Invalid signature")
    
    payload = await request.json()
    
    # 2. Handle verification challenge (initial setup)
    if payload.get("type") == "url_verification":
        return {"challenge": payload["challenge"]}
    
    # 3. Handle real event - enqueue for async processing
    if payload.get("type") == "event_callback":
        event = payload["event"]
        if event["type"] == "app_mention":
            await queue.enqueue({
                "user": event["user"],
                "text": event["text"],
                "channel": event["channel"],
                "ts": event["ts"]
            })
    
    # 4. Respond ASAP - actual processing happens in the worker
    return {"ok": True}

6. Process async

The worker pulls from the queue, processes with the LLM, and posts the response back to Slack via an API call (not via the bot's outbound webhook).


AI-specific cases

Case 1: Slack bot for Q&A

The inbound webhook receives messages mentioning the bot:

User: @ai_bot how do I configure Redis?
↓
Slack webhook → your API
↓ fast ACK
Queue
↓
Worker pulls
↓
RAG retrieval + LLM call (5s)
↓
Worker calls the Slack chat.postMessage API
↓
User sees the response in the thread

Case 2: Discord bot with slash commands

Discord uses the interactions API (a variation of webhooks):

User: /ask how do I configure Redis?
↓
Discord webhook → your API (3 sec timeout)
↓ ACK with "deferred response" (extended to 15 min)
↓
Async processing
↓
Worker calls the Discord followup endpoint
↓
User sees the response

Case 3: GitHub events for auto-PR review

PR opened in a repo
↓
GitHub webhook → your API
↓ ACK
↓
Worker: clone diff, RAG over history, LLM analyzes
↓
Comment on the PR via the GitHub API

Case 4: Sentry / error tracking integration

Error occurs in the app
↓
Sentry webhook → your API
↓ ACK
↓
Worker: classify error, RAG over similar issues, LLM suggests a fix
↓
Comment on Sentry or create a Linear ticket

Common pitfalls and mistakes

Pitfall 1: Processing synchronously when the provider has a timeout

A junior engineer receives the webhook, makes an LLM call directly, returns a response. The LLM takes 5s. The Slack timeout is 3s. Slack retries 3 times. Your system processes the same event 3 times, duplicated LLM calls, possible duplicated responses to the user.

How to detect it: does your webhook handler do heavy processing before responding? Have you seen events that seem to be processed multiple times?

How to fix it: separate receipt from processing. Receipt: verify signature, enqueue, return 200. Processing: async worker.

Pitfall 2: Not verifying signatures

An engineer omits verification "because only Slack sends to this URL". An attacker finds the URL (it's not secret), sends fake requests. Your system processes fake events as real.

How to detect it: does your webhook handler verify the signature with HMAC? If it only verifies the format of the payload, it's not protection.

How to fix it: signature verification as the first step. Constant-time comparison. Reject old timestamps.

Pitfall 3: Ignoring idempotency

The same event can arrive several times (provider retries, network issues). If your handler does non-idempotent operations, duplicated side effects.

How to detect it: do your webhooks execute actions with externally visible effects? What happens if they receive the same event twice?

How to fix it: each webhook has a unique event ID. Track processed IDs (Redis with 24h TTL). Skip if the ID is already processed.

async def process_event(event_id: str, payload: dict):
    if await redis.exists(f"processed:{event_id}"):
        return  # Already processed
    
    # Process
    await actually_process(payload)
    
    # Mark processed
    await redis.setex(f"processed:{event_id}", 86400, "1")

Pitfall 4: Webhook handler as a single point of failure

If your webhook handler crashes, events are lost. The provider will retry but limitedly. Then data lost.

How to detect it: what happens if your webhook handler is down for 10 minutes?

How to fix it: real HA (multiple instances), aggressive monitoring (alerts on errors), a DLQ for events that fail repeatedly.

Pitfall 5: Complete logging of the payload

Your webhook payloads have sensitive data (PII, tokens, etc.). Complete logging to Datadog means that data travels outside your security boundary.

How to detect it: do your logs have complete webhook payloads? Does any external logging service see that data?

How to fix it: log metadata (timestamps, IDs, types), not full payloads. If you need to debug, log to internal storage only.


Worked example: Slack bot Q&A end-to-end

Complete implementation:

import os
import hmac
import hashlib
import time
import json
from fastapi import FastAPI, Request, HTTPException
import redis.asyncio as redis

app = FastAPI()
r = redis.from_url("redis://localhost:6379")

SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]

def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 60 * 5:
        return False
    
    base_string = f"v0:{timestamp}:{body.decode('utf-8')}"
    expected = "v0=" + hmac.new(
        SLACK_SIGNING_SECRET.encode(),
        base_string.encode(),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(expected, signature)

@app.post("/webhooks/slack")
async def slack_webhook(request: Request):
    # 1. Read body
    body = await request.body()
    
    # 2. Verify signature
    timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
    signature = request.headers.get("X-Slack-Signature", "")
    
    if not timestamp or not signature:
        raise HTTPException(401, "Missing signature headers")
    
    if not verify_slack_signature(body, timestamp, signature):
        raise HTTPException(401, "Invalid signature")
    
    payload = json.loads(body)
    
    # 3. Handle verification challenge
    if payload.get("type") == "url_verification":
        return {"challenge": payload["challenge"]}
    
    # 4. Handle event
    if payload.get("type") == "event_callback":
        event = payload["event"]
        event_id = payload.get("event_id")
        
        # Idempotency check
        if event_id:
            already_processed = await r.exists(f"slack:processed:{event_id}")
            if already_processed:
                return {"ok": True}  # Skip duplicates
        
        # Filter events we care about
        if event["type"] == "app_mention":
            # Enqueue for async processing
            await r.xadd(
                "slack_queries",
                {
                    "event_id": event_id or "",
                    "user": event["user"],
                    "text": event["text"],
                    "channel": event["channel"],
                    "ts": event["ts"],
                }
            )
            
            # Mark processed
            if event_id:
                await r.setex(f"slack:processed:{event_id}", 86400, "1")
    
    # 5. Respond ASAP
    return {"ok": True}

Worker in a separate process:

# worker/process_slack.py
import asyncio
import redis.asyncio as redis
from src.services.query_service import QueryService
from src.services.slack_client import SlackClient

async def consume():
    r = redis.from_url("redis://localhost:6379")
    consumer_group = "slack_workers"
    
    try:
        await r.xgroup_create("slack_queries", consumer_group, mkstream=True)
    except redis.ResponseError:
        pass
    
    query_service = QueryService(...)
    slack = SlackClient(token=os.environ["SLACK_BOT_TOKEN"])
    
    while True:
        messages = await r.xreadgroup(
            consumer_group, "worker_1",
            {"slack_queries": ">"}, count=1, block=5000
        )
        
        if not messages:
            continue
        
        for stream, msgs in messages:
            for msg_id, data in msgs:
                try:
                    user_text = data[b"text"].decode()
                    channel = data[b"channel"].decode()
                    thread_ts = data[b"ts"].decode()
                    
                    # Strip bot mention
                    query = user_text.split(">", 1)[1].strip()
                    
                    # Show "thinking..." immediately
                    await slack.post_message(
                        channel=channel,
                        thread_ts=thread_ts,
                        text="🤔 Researching..."
                    )
                    
                    # Process (takes 5-15s)
                    response = await query_service.process(query)
                    
                    # Post response
                    await slack.post_message(
                        channel=channel,
                        thread_ts=thread_ts,
                        text=response.text
                    )
                    
                    # Ack
                    await r.xack("slack_queries", consumer_group, msg_id)
                    
                except Exception as e:
                    print(f"Error processing {msg_id}: {e}")
                    # Don't ack - it will be retried

Patterns applied:

  • ✅ Signature verification
  • ✅ Idempotency (Redis tracking)
  • ✅ Verification challenge handling
  • ✅ Quick ACK
  • ✅ Async processing
  • ✅ Error handling

Self-check

For a webhook integration you know or want to design, identify the critical aspects:

AspectYour approachOK?
Signature verification??
Verification challenge??
Sync processing??
Idempotency??
Timeout handling??
Error handling + retry??
See example case (Discord bot)
AspectApproach
Signature verificationDiscord uses Ed25519, verify with the nacl library
Verification challengeDiscord requires a PING response in the handshake
Sync processingUse deferred response (15min window)
IdempotencyTrack interaction.id in Redis
Timeout handlingDefer response immediately, async processing in the worker
Error handlingEdit the deferred response with an error message if it fails

Summary and next step

In this capsule you learned inbound webhooks:

  • Complete flow: registration → verification challenge → events with signature
  • Verification critical: signatures, timestamps, constant-time comparison
  • ACK + async pattern: respond fast (3s), async processing via queue
  • Idempotency: track event IDs to handle duplicates
  • AI use cases: Slack bots, Discord bots, GitHub events, error tracking

Before moving on to capsule 03, you should be able to:

  • Implement HMAC signature verification
  • Design the ACK + async pattern for AI webhooks
  • Identify 3+ common pitfalls in webhook handlers
  • Articulate why idempotency is critical

In capsule 03Webhooks Outbound — you're going to see the other side: how YOUR system notifies externals via webhooks. AI cases: notify when a long-running job finishes, alert an admin of problematic queries, integrate with Slack/Discord to post an update. You're going to learn retry patterns, signing your webhooks, exponential backoff, dead letter queues — the operational aspects of outbound webhooks.


Resources