Chapter 4: Networking Fundamentals for Chat Integrations

Learning Objectives

Pre-Reading Check — HTTP for Integrators

An HTTP request is composed of which four parts?

Status code, headers, body, and cookies
Method, path, headers, and an optional body
URL, port, protocol, and payload
Verb, certificate, session key, and body

Your gateway sends a request and receives a 503 Service Unavailable. What is the appropriate response?

Fix the request body, because a 5xx means you sent something invalid
Retry with exponential backoff, because 5xx errors are usually transient
Stop permanently, because 5xx errors are never recoverable
Follow the Location header to a new endpoint

Why is retrying a POST that creates a message dangerous, and what makes it safe?

POST is naturally idempotent, so retries are always safe with no extra work
Retrying a POST can duplicate the message; an idempotency key lets the server recognize the retry
POST retries are safe only over HTTP/2, which deduplicates automatically
Retrying a POST is safe as long as you change the Authorization header each time

Slack's Web API can return an HTTP 200 OK while still signaling a failure. How should an integrator handle this?

Trust the 200 and assume success, since the HTTP layer is authoritative
Check both the HTTP status code and an application-level field such as "ok": false in the body
Retry the request until it returns a 201 instead of a 200
Ignore the body entirely and rely only on response headers

When an API returns 429 Too Many Requests with a Retry-After header, the well-behaved gateway should:

Immediately retry the same request as fast as possible
Treat it as a permanent 4xx failure and give up
Wait the number of seconds indicated by Retry-After before retrying
Switch to a different HTTP method to bypass the limit

HTTP for Integrators

Key Points

HTTP (HyperText Transfer Protocol) is the request/response protocol that every REST-style chat API speaks. A client sends a request; a server returns a response. The details of methods, headers, bodies, and status codes are exactly what separate a fragile integration from a robust one.

The methods you will use most as an integrator map to CRUD operations:

MethodMeaningExample in a gateway
GETRead a resourceFetch a channel's message history
POSTCreate / submitPost a reply message to Slack
PUTReplace a resourceOverwrite a saved user preference
PATCHPartially updateEdit one field of a message
DELETERemove a resourceDelete a bot message

Headers carry the metadata that makes the request work: Content-Type: application/json tells the server how to parse the body, Authorization: Bearer <token> proves who you are, and User-Agent identifies your client. The body — for chat APIs, almost always JSON — carries the actual data.

Status codes are grouped into five classes by their leading digit, and knowing the class tells you how to react:

ClassMeaningIntegrator response
1xxInformationalRarely seen directly
2xxSuccessProceed; 200 OK, 201 Created, 204 No Content
3xxRedirectionFollow the Location (clients usually do this for you)
4xxClient error — you sent something wrongFix the request; do not blindly retry
5xxServer error — they failedSafe to retry with backoff

The distinction between 4xx and 5xx is the single most important operational rule in this chapter. A 400 or 401 means retrying the identical request will fail identically. A 500 or 503 is usually transient. The one 4xx you do retry is 429 Too Many Requests.

Figure 4.3: The HTTP request/response cycle for a gateway outbound call.

sequenceDiagram participant Client as "Gateway (client)" participant Server as "Chat API (server)" Client->>Server: "Request: POST /chat.postMessage" Note over Client,Server: "Headers: Authorization, Content-Type + JSON body" Server->>Server: "Process request" alt Success Server-->>Client: "200 OK / 201 Created + JSON body" else Client error (4xx) Server-->>Client: "400 / 401 / 403 (fix request, do not retry)" else Rate limited Server-->>Client: "429 + Retry-After header (wait, then retry)" else Server error (5xx) Server-->>Client: "500 / 503 (retry with backoff)" end

Visual animation — coming soon

An operation is idempotent if performing it many times has the same effect as performing it once. GET and DELETE are naturally idempotent; POST is the dangerous one, because naively retrying a "post message" request can produce two identical messages — the classic double-send bug. Because networks fail ambiguously (a lost response looks identical to a lost request), the industry answer is an idempotency key: a unique client-generated identifier that lets the server recognize a retry and return the original result instead of acting twice.

Chat and messaging APIs protect themselves with rate limits. When you exceed one, the API returns 429 Too Many Requests, typically with a Retry-After header telling you how many seconds to wait. A well-behaved gateway honors it rather than hammering the endpoint.

REST (Representational State Transfer) is the architectural style most chat platform APIs follow: resources live at noun-based URLs (/channels/{id}/messages), the method expresses the action, JSON is the interchange format, and authentication travels in the Authorization header. Slack notably can return 200 OK at the HTTP layer while signaling a logical failure via an "ok": false field — so you must check both the status code and the payload.

Post-Reading Check — HTTP for Integrators

An HTTP request is composed of which four parts?

Status code, headers, body, and cookies
Method, path, headers, and an optional body
URL, port, protocol, and payload
Verb, certificate, session key, and body

Your gateway sends a request and receives a 503 Service Unavailable. What is the appropriate response?

Fix the request body, because a 5xx means you sent something invalid
Retry with exponential backoff, because 5xx errors are usually transient
Stop permanently, because 5xx errors are never recoverable
Follow the Location header to a new endpoint

Why is retrying a POST that creates a message dangerous, and what makes it safe?

POST is naturally idempotent, so retries are always safe with no extra work
Retrying a POST can duplicate the message; an idempotency key lets the server recognize the retry
POST retries are safe only over HTTP/2, which deduplicates automatically
Retrying a POST is safe as long as you change the Authorization header each time

Slack's Web API can return an HTTP 200 OK while still signaling a failure. How should an integrator handle this?

Trust the 200 and assume success, since the HTTP layer is authoritative
Check both the HTTP status code and an application-level field such as "ok": false in the body
Retry the request until it returns a 201 instead of a 200
Ignore the body entirely and rely only on response headers

When an API returns 429 Too Many Requests with a Retry-After header, the well-behaved gateway should:

Immediately retry the same request as fast as possible
Treat it as a permanent 4xx failure and give up
Wait the number of seconds indicated by Retry-After before retrying
Switch to a different HTTP method to bypass the limit
Pre-Reading Check — TLS, Auth, and Verification

TLS provides encryption, integrity, and server authentication. What does TLS not guarantee about an inbound webhook?

That the channel is encrypted end to end
That the data was not altered in transit
That whoever opened the connection is really the platform (e.g., Slack)
That the server presented a valid certificate

What is the difference between a bearer token and a signing secret in a gateway?

A bearer token authenticates outbound calls; a signing secret authenticates inbound webhooks
A bearer token authenticates inbound webhooks; a signing secret authenticates outbound calls
They are interchangeable names for the same credential
A bearer token encrypts the channel; a signing secret decrypts it

Why must you capture the raw request body before parsing JSON when verifying an HMAC signature?

JSON parsing is too slow to do before verification
Re-serializing parsed JSON can reorder keys or change whitespace, breaking the signature match
The raw body is encrypted and must be decrypted first
Parsing JSON strips the Authorization header needed for HMAC

Why does Slack's verification use hmac.compare_digest instead of ==?

== cannot compare byte strings in Python
compare_digest runs in constant time, preventing a timing attack that could leak the signature byte by byte
compare_digest is faster than == for long strings
== automatically decodes hex, which corrupts the comparison

Rejecting requests whose timestamp is older than five minutes primarily defends against what?

Timing attacks on the signature comparison
Replay attacks, where an attacker re-sends a captured legitimate request later
TLS certificate expiration
Rate-limit violations from the platform

TLS, Auth, and Verification

Key Points

TLS (Transport Layer Security, the "S" in HTTPS) turns a plaintext TCP connection into an encrypted, integrity-protected channel. It does three things: encryption (eavesdroppers see only ciphertext), integrity (tampering is detected), and authentication (the client verifies the server's identity via its certificate).

The setup happens in a TLS handshake performed once when the connection opens. The client and server agree on a cipher, the server presents an X.509 certificate signed by a trusted Certificate Authority, the client validates it, and the two derive a shared session key. The handshake involves multiple round trips and public-key cryptography, making it computationally and latency-expensive — a fact central to connection pooling. Every reused connection saves a full handshake.

For a gateway this means webhook endpoints must be served over HTTPS. But TLS guarantees only that the channel is confidential; it does not prove that whoever POSTed to your public URL is really Slack. Anyone on the internet can open a TLS connection. That gap is what HMAC verification closes.

Figure 4.4: The TLS handshake that opens an encrypted channel.

sequenceDiagram participant Client participant Server Client->>Server: "ClientHello (supported ciphers, random)" Server-->>Client: "ServerHello (chosen cipher, random)" Server-->>Client: "X.509 certificate (signed by trusted CA)" Client->>Client: "Validate certificate against CA trust store" Client->>Server: "Key exchange (public-key crypto)" Note over Client,Server: "Both sides derive the shared session key" Client->>Server: "Finished (encrypted)" Server-->>Client: "Finished (encrypted)" Note over Client,Server: "Application data now encrypted with session key"

Two distinct secrets govern trust. A bearer token (Slack's xoxb-...) authenticates outbound calls — the requests you make to the platform, placed in the Authorization: Bearer <token> header. A signing secret authenticates inbound calls — the webhooks the platform sends you. It is never transmitted; instead it is used to compute an HMAC signature over each request.

HMAC (Hash-based Message Authentication Code) combines a hash function — SHA-256 for Slack — with the shared secret. Because both sides hold the secret, both can compute the same HMAC over the same bytes; a third party cannot forge a valid one. Slack's procedure:

  1. Capture the raw request body — the exact bytes Slack sent — before any JSON parsing. Re-serializing parsed JSON can reorder keys or change whitespace and silently break verification.
  2. Check the timestamp for freshness using X-Slack-Request-Timestamp. Reject anything older than five minutes to defend against replay attacks.
  3. Build the signature base string: v0:{timestamp}:{body}.
  4. Compute HMAC-SHA256 of that base string using the signing secret as key, hex-encode it, and prefix v0=.
  5. Compare securely against X-Slack-Signature with a constant-time comparison — never ==.
import hashlib
import hmac
import time

def verify_slack(signing_secret: str, timestamp: str, body: bytes, sig: str) -> bool:
    # Step 2: reject stale requests to block replay attacks
    if abs(time.time() - int(timestamp)) > 60 * 5:
        return False

    # Step 3: reconstruct the exact base string Slack signed
    base = f"v0:{timestamp}:{body.decode()}".encode()

    # Step 4: recompute the HMAC-SHA256 signature with the shared secret
    computed = "v0=" + hmac.new(
        signing_secret.encode(), base, hashlib.sha256
    ).hexdigest()

    # Step 5: constant-time comparison, NOT ==
    return hmac.compare_digest(computed, sig)

Why constant-time comparison? A naive computed == sig short-circuits at the first differing byte, so its runtime leaks how many leading bytes matched. An attacker measuring response times could recover the correct signature byte by byte — a timing attack. hmac.compare_digest compares in time independent of where the strings differ.

Figure 4.5: HMAC signature verification flow for an inbound webhook.

flowchart TD A["Inbound webhook POST arrives"] --> B["Capture raw request body (before JSON parse)"] B --> C{"Timestamp within 5 minutes?"} C -->|No| R["Reject: 401 / 403 (possible replay)"] C -->|Yes| D["Build base string: v0:timestamp:body"] D --> E["Compute HMAC-SHA256 with signing secret"] E --> F{"compare_digest(computed, X-Slack-Signature)?"} F -->|No match| R F -->|Match| G["Parse JSON and enqueue event"] G --> H["Return 200 (fast-ack)"]

Figure 4.1: Two secrets, two directions.

flowchart LR G["Gateway"] -->|"Bearer token in Authorization header (authenticates outbound calls)"| S["Slack API"] P["Slack (platform)"] -->|"HMAC signature over raw body, verified with signing secret (authenticates inbound webhooks)"| G

Visual animation — coming soon

Your webhook URL is public by necessity, which means it is also public to attackers. Without signature verification, anyone who discovers the URL can POST forged events. TLS does not help; the attacker simply opens their own valid TLS connection. Signature verification is therefore a mandatory trust boundary: read the raw body first, verify before parsing, return 401/403 on failure, and rotate the signing secret immediately if it leaks.

Post-Reading Check — TLS, Auth, and Verification

TLS provides encryption, integrity, and server authentication. What does TLS not guarantee about an inbound webhook?

That the channel is encrypted end to end
That the data was not altered in transit
That whoever opened the connection is really the platform (e.g., Slack)
That the server presented a valid certificate

What is the difference between a bearer token and a signing secret in a gateway?

A bearer token authenticates outbound calls; a signing secret authenticates inbound webhooks
A bearer token authenticates inbound webhooks; a signing secret authenticates outbound calls
They are interchangeable names for the same credential
A bearer token encrypts the channel; a signing secret decrypts it

Why must you capture the raw request body before parsing JSON when verifying an HMAC signature?

JSON parsing is too slow to do before verification
Re-serializing parsed JSON can reorder keys or change whitespace, breaking the signature match
The raw body is encrypted and must be decrypted first
Parsing JSON strips the Authorization header needed for HMAC

Why does Slack's verification use hmac.compare_digest instead of ==?

== cannot compare byte strings in Python
compare_digest runs in constant time, preventing a timing attack that could leak the signature byte by byte
compare_digest is faster than == for long strings
== automatically decodes hex, which corrupts the comparison

Rejecting requests whose timestamp is older than five minutes primarily defends against what?

Timing attacks on the signature comparison
Replay attacks, where an attacker re-sends a captured legitimate request later
TLS certificate expiration
Rate-limit violations from the platform
Pre-Reading Check — Async HTTP Clients

Why is asyncio a natural fit for a gateway's outbound HTTP work?

The work is CPU-bound, so async spreads it across cores
The work is I/O-bound, so one event loop can keep hundreds of in-flight requests alive while waiting
Async removes the need for timeouts entirely
Async guarantees requests never fail

What is the single most important rule for preserving connection pooling and keep-alive?

Create a new AsyncClient or ClientSession for every request
Instantiate one long-lived client and reuse it everywhere
Disable keep-alive so connections close immediately
Open a fresh TLS handshake before each call

What is a key difference in timeout behavior between httpx and aiohttp?

httpx has no default timeout, while aiohttp defaults to 5s
Both apply a 30-second default and cannot be changed
httpx defaults to 5s per phase; aiohttp has none and counts pool-queue wait against the clock
Neither library supports configurable timeouts

Which set of errors is safe to retry with exponential backoff?

400 and 401, because the request is malformed
429, 500, 502, 503, 504, and connection/timeout failures
Only 200 responses
Every 4xx error should be retried aggressively

Why add jitter to an exponential-backoff retry schedule?

It makes the retries happen sooner
It encrypts the retry request
A small random offset prevents many clients from aligning into synchronized retry waves
It converts a POST into an idempotent request

Async HTTP Clients

Key Points

A gateway spends most of its life waiting on the network. Because this work is I/O-bound rather than CPU-bound, asyncio is the natural fit: one event loop keeps hundreds of in-flight requests alive, switching to whichever has data ready. Two libraries dominate async HTTP in Python:

httpxaiohttp
Sync + async?Both, one libraryAsync-only
API styleRequests-compatibleSession-based
Default timeout5s (connect/read/write/pool)None by default
Pool confighttpx.LimitsTCPConnector
Per-host capNoYes (limit_per_host)

The canonical concurrent-fan-out pattern combines a shared client with asyncio.gather:

import asyncio
import httpx

async def fetch(client: httpx.AsyncClient, url: str):
    r = await client.get(url, timeout=10.0)
    r.raise_for_status()
    return r.json()

async def fetch_all(urls: list[str]):
    async with httpx.AsyncClient() as client:
        return await asyncio.gather(*(fetch(client, u) for u in urls))

Recall that opening a new TCP connection — and, over HTTPS, a full TLS handshake — is expensive. A connection pool amortizes that cost by reusing established connections; this reuse is HTTP keep-alive. The single most important rule: instantiate one long-lived client and reuse it everywhere. Creating a new client per request defeats pooling entirely and leaks connections. In httpx, the pool is tuned with httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0). In aiohttp, the same knobs live on the TCPConnector, whose limit_per_host keeps you a polite client to a rate-limited API.

Timeouts are non-negotiable: never issue a network request without one. httpx applies a 5-second default independently to four phases — connect, read, write, and pool. aiohttp uses ClientTimeout with one subtlety: its clock starts the moment session.get() is called, so time spent queued waiting for a free connection counts against the timeout.

Concernhttpxaiohttp
Timeout defaults5s per phaseNone — you must set it
Timeout clock startsPer-phaseAt session.get(), includes pool wait
Total pool capLimits(max_connections=...)TCPConnector(limit=...)
Per-host capTCPConnector(limit_per_host=...)

Upstreams fail transiently. Retrying blindly and immediately makes things worse: a synchronized retry storm can knock a recovering service back over. The standard remedy is exponential backoff — wait progressively longer (1s, 2s, 4s, 8s) — plus jitter, a small random offset so many clients don't align into synchronized waves. Two rules keep retries safe: only retry retryable errors (429, 500, 502, 503, 504, connection/timeout failures — never a 400 or 401), and pair retries with idempotency.

import asyncio
import random
import httpx

RETRYABLE = {429, 500, 502, 503, 504}

async def post_with_backoff(client, url, json, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            r = await client.post(url, json=json, timeout=10.0)
            if r.status_code not in RETRYABLE:
                r.raise_for_status()   # 2xx succeeds; 4xx fails fast
                return r.json()
            # honor Retry-After on 429 if present
            wait = float(r.headers.get("Retry-After", 2 ** attempt))
        except (httpx.ConnectError, httpx.ReadTimeout):
            wait = 2 ** attempt
        wait += random.uniform(0, 1)   # jitter
        await asyncio.sleep(wait)
    raise RuntimeError(f"exhausted retries for {url}")

Visual animation — coming soon

Post-Reading Check — Async HTTP Clients

Why is asyncio a natural fit for a gateway's outbound HTTP work?

The work is CPU-bound, so async spreads it across cores
The work is I/O-bound, so one event loop can keep hundreds of in-flight requests alive while waiting
Async removes the need for timeouts entirely
Async guarantees requests never fail

What is the single most important rule for preserving connection pooling and keep-alive?

Create a new AsyncClient or ClientSession for every request
Instantiate one long-lived client and reuse it everywhere
Disable keep-alive so connections close immediately
Open a fresh TLS handshake before each call

What is a key difference in timeout behavior between httpx and aiohttp?

httpx has no default timeout, while aiohttp defaults to 5s
Both apply a 30-second default and cannot be changed
httpx defaults to 5s per phase; aiohttp has none and counts pool-queue wait against the clock
Neither library supports configurable timeouts

Which set of errors is safe to retry with exponential backoff?

400 and 401, because the request is malformed
429, 500, 502, 503, 504, and connection/timeout failures
Only 200 responses
Every 4xx error should be retried aggressively

Why add jitter to an exponential-backoff retry schedule?

It makes the retries happen sooner
It encrypts the retry request
A small random offset prevents many clients from aligning into synchronized retry waves
It converts a POST into an idempotent request
Pre-Reading Check — Async HTTP Servers

What is ASGI, and how does it relate to WSGI?

ASGI is the synchronous predecessor to WSGI
ASGI is the async successor to WSGI, defining an app callable of scope, receive, and send
ASGI and WSGI are the same interface with different names
ASGI is a database driver; WSGI is a web server

How does a single uvicorn process achieve concurrency?

By spawning one thread per incoming request
By running one cooperative event loop that switches tasks whenever a handler awaits on I/O
By using multiple CPU cores within one process automatically
By blocking on each request until it completes

Why must you use an async HTTP client like httpx inside an async handler?

Blocking clients are not installable alongside FastAPI
A blocking call in the single-threaded loop stalls every other request, negating ASGI's concurrency
Async clients are the only ones that support HTTPS
FastAPI rejects any handler that calls a synchronous library

How do you use all CPU cores with uvicorn?

A single uvicorn process automatically uses every core
Run multiple uvicorn workers (often under Gunicorn), each an independent event loop on its own core
Increase the event loop's thread count with a flag
Switch from ASGI to WSGI to unlock multiprocessing

What is the "fast-ack" pattern for a webhook endpoint?

Run the slow LLM call first, then acknowledge once you have a full answer
Verify the signature, enqueue the event, and return 200 immediately, deferring slow work to a background worker
Return 202 Accepted and never process the event
Hold the connection open until processing finishes to avoid retries

Async HTTP Servers

Key Points

To receive webhooks and serve API endpoints, the gateway needs an HTTP server that holds many connections open without a thread per connection. That comes from the ASGI stack: a framework like FastAPI running on a server like uvicorn.

ASGI (Asynchronous Server Gateway Interface) is the standard contract between a Python web server and an async application — the asynchronous successor to WSGI, the older synchronous standard used by Flask and Django. Under WSGI, each request occupies a worker thread for its entire lifetime; handling N slow requests concurrently needs roughly N threads. ASGI removes that constraint with an async interface and natively supports WebSockets and server-sent events. Concretely, an ASGI application is an async callable receiving scope (a dict describing the connection) plus two coroutine channels, receive and send. You rarely write ASGI by hand: FastAPI is built on Starlette, which supplies routing, middleware, and WebSocket support.

Uvicorn is a lightweight, high-performance ASGI server built on asyncio. Its speed comes from two C-level libraries: uvloop, a drop-in replacement for the standard event loop that runs roughly 2–4× faster, and httptools for fast HTTP parsing. Here is a complete minimal server that verifies a Slack signature and responds:

from fastapi import FastAPI, Request, HTTPException
import os

app = FastAPI()
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]

@app.post("/slack/events")
async def slack_events(request: Request):
    # Read the RAW body first — before any JSON parsing
    body = await request.body()
    ts = request.headers.get("X-Slack-Request-Timestamp", "0")
    sig = request.headers.get("X-Slack-Signature", "")

    if not verify_slack(SIGNING_SECRET, ts, body, sig):
        raise HTTPException(status_code=401, detail="bad signature")

    payload = await request.json()          # safe to parse now
    # ... hand off to a queue (Chapter 8) instead of processing here ...
    return {"ok": True}                      # fast acknowledgement

Run it with uvicorn main:app --host 0.0.0.0 --port 8000. A single uvicorn process runs one event loop on one CPU core. Concurrency is cooperative: when a handler hits an await on I/O, it yields to the event loop, which immediately runs another ready task. The critical caveat: the loop is single-threaded, so a CPU-bound or blocking call stalls every other request. This is exactly why you must use an async client like httpx inside async handlers.

To use all CPU cores and add fault isolation, run multiple uvicorn workers, commonly supervised by Gunicorn (gunicorn -k uvicorn.workers.UvicornWorker -w 4). This two-level model — async concurrency within a worker, process parallelism across workers — is the standard production deployment.

Figure 4.2: Two levels of concurrency.

flowchart LR LB["Load balancer"] --> W1["Uvicorn worker 1 (CPU core 1)"] LB --> W2["Uvicorn worker 2 (CPU core 2)"] LB --> W3["Uvicorn worker 3 (CPU core 3)"] LB --> W4["Uvicorn worker 4 (CPU core 4)"] subgraph EL["Inside worker 1: one event loop"] direction LR R1["Request A: await on I/O"] R2["Request B: await on I/O"] R3["Request C: ready to run"] end W1 --> EL

Visual animation — coming soon

Chat platforms impose tight acknowledgement deadlines — Slack expects a response within about three seconds and retries a slow reply. If your endpoint runs a slow LLM call before responding, you will blow the deadline and the retry triggers duplicate processing. The correct pattern is fast-ack: verify the signature, do the minimum work to enqueue the event, and return 200 immediately. The heavy lifting happens afterward in a background worker consuming from a queue.

Post-Reading Check — Async HTTP Servers

What is ASGI, and how does it relate to WSGI?

ASGI is the synchronous predecessor to WSGI
ASGI is the async successor to WSGI, defining an app callable of scope, receive, and send
ASGI and WSGI are the same interface with different names
ASGI is a database driver; WSGI is a web server

How does a single uvicorn process achieve concurrency?

By spawning one thread per incoming request
By running one cooperative event loop that switches tasks whenever a handler awaits on I/O
By using multiple CPU cores within one process automatically
By blocking on each request until it completes

Why must you use an async HTTP client like httpx inside an async handler?

Blocking clients are not installable alongside FastAPI
A blocking call in the single-threaded loop stalls every other request, negating ASGI's concurrency
Async clients are the only ones that support HTTPS
FastAPI rejects any handler that calls a synchronous library

How do you use all CPU cores with uvicorn?

A single uvicorn process automatically uses every core
Run multiple uvicorn workers (often under Gunicorn), each an independent event loop on its own core
Increase the event loop's thread count with a flag
Switch from ASGI to WSGI to unlock multiprocessing

What is the "fast-ack" pattern for a webhook endpoint?

Run the slow LLM call first, then acknowledge once you have a full answer
Verify the signature, enqueue the event, and return 200 immediately, deferring slow work to a background worker
Return 202 Accepted and never process the event
Hold the connection open until processing finishes to avoid retries

Your Progress

Answer Explanations