Chapter 5: Webhooks: Receiving Events from Slack, Discord, and WebEx
Learning Objectives
Explain the webhook push model and its trade-offs versus persistent connections.
Implement a signature-verified webhook receiver for a chat platform.
Handle platform-specific handshakes, retries, and acknowledgement deadlines.
Design a webhook endpoint that acknowledges quickly and defers heavy work to a background worker.
Pre-Reading Check — The Webhook Model
1. In the webhook push model, which party acts as the HTTP client when an event fires?
Your gateway, because it registered the URL
The platform (Slack/Discord/WebEx), which POSTs the event to you
The end user's chat client
The load balancer sitting in front of your endpoint
2. Why must a webhook receiver authenticate every inbound request, unlike a client holding a persistent connection?
Because HTTP is slower than WebSockets and needs extra checks
Because the URL is public, so anyone on the internet can POST to it
Because webhooks cannot use TLS encryption
Because the platform never re-sends events, so each one is precious
3. During local development, why is a tunnel such as ngrok needed to test webhooks?
It encrypts the JSON payload the platform sends
It exposes your localhost server behind a public HTTPS URL the platform can reach
It caches events so you can replay them offline
It signs requests on the platform's behalf
4. What does "at-least-once delivery" imply for how you must design event processing?
Every event arrives exactly once, so no special handling is needed
Some events may be dropped, so you must poll to fill gaps
The same event can arrive more than once, so processing must tolerate duplicates
Events arrive in strict order, so you can rely on sequence numbers
5. What signal tells the platform your endpoint successfully received an event, preventing a retry?
An HTTP 2xx response returned in time
A matching HMAC signature in your response headers
Echoing the event ID back in the body
Closing the TCP connection without any status code
The Webhook Model
Key Points
A webhook inverts client/server roles: the platform becomes the HTTP client and POSTs events to a public URL you host.
Because the URL is public, you trade the overhead of a persistent connection for the obligation to authenticate every request.
Endpoints must be publicly reachable over HTTPS — a tunnel (ngrok) in development, a stable cloud endpoint with a real certificate in production.
Delivery is at-least-once: the platform retries until it gets a 2xx, so duplicate events are routine, not an edge case.
A webhook is a "push" delivery mechanism: instead of your gateway holding open a persistent connection and pulling events, the platform issues an ordinary outbound HTTP POST to a public URL you register whenever something happens. This inverts the usual client/server roles for that request — when a message is posted in Slack, Slack's servers become the HTTP client and your endpoint becomes the server.
An analogy: a persistent WebSocket connection is like waiting in line at a bank teller's window. A webhook is like giving the bank your phone number and hanging up — they call you when your transaction clears. This frees you from holding a line open, but it means you must publish a phone number the whole world can dial, and decide on every call whether the caller is really who they claim to be.
Figure 5.3: The webhook push model — the platform becomes the HTTP client.
sequenceDiagram
participant User as User in channel
participant Platform as "Platform (Slack/Discord/WebEx)"
participant Gateway as "Your gateway (public HTTPS URL)"
User->>Platform: Posts a message / triggers an event
Note over Platform: Event occurs
Platform->>Gateway: HTTP POST event payload
Note over Gateway: Platform is the client, your endpoint is the server
Gateway-->>Platform: HTTP 2xx acknowledgement
Visual animation — coming soon
Because anyone on the internet can POST to your URL, two problems must be solved before any event is trusted: proving the endpoint belongs to you (a one-time handshake) and proving each subsequent request genuinely came from the platform and was not tampered with (per-request signature validation).
Public reachability, tunnels, and cloud endpoints
Because the platform initiates the connection, your endpoint must be reachable from the public internet. In production this is a cloud endpoint with a stable HTTPS URL behind a load balancer with a real TLS certificate. During development your gateway runs on localhost, which the platform's servers cannot reach. The standard fix is a tunnel: a tool such as ngrok opens an outbound connection from your laptop to a public relay and gives you a temporary public HTTPS URL that forwards inbound requests to your local port.
Two practical notes. Webhook URLs must be HTTPS in production; platforms will not deliver over plaintext HTTP. And a free ngrok URL changes every restart, so you must re-register the new URL (and re-run the verification handshake) each session.
At-least-once delivery, retries, and duplicates
Webhook platforms guarantee at-least-once delivery: every event will be delivered at least once, retrying on failure until the receiver acknowledges it. The signal for "success" is your HTTP 2xx response. If the platform does not receive a timely 2xx — because your handler was slow, crashed, or the network hiccuped — it treats the delivery as failed and retries. Slack, for instance, retries a failed event up to three times with backoff (immediately, then after 1 minute, then after 5 minutes). The unavoidable consequence: the same event can arrive more than once, so you engineer processing to tolerate duplicates rather than chasing impossible exactly-once delivery.
Post-Reading Check — The Webhook Model
1. In the webhook push model, which party acts as the HTTP client when an event fires?
Your gateway, because it registered the URL
The platform (Slack/Discord/WebEx), which POSTs the event to you
The end user's chat client
The load balancer sitting in front of your endpoint
2. Why must a webhook receiver authenticate every inbound request, unlike a client holding a persistent connection?
Because HTTP is slower than WebSockets and needs extra checks
Because the URL is public, so anyone on the internet can POST to it
Because webhooks cannot use TLS encryption
Because the platform never re-sends events, so each one is precious
3. During local development, why is a tunnel such as ngrok needed to test webhooks?
It encrypts the JSON payload the platform sends
It exposes your localhost server behind a public HTTPS URL the platform can reach
It caches events so you can replay them offline
It signs requests on the platform's behalf
4. What does "at-least-once delivery" imply for how you must design event processing?
Every event arrives exactly once, so no special handling is needed
Some events may be dropped, so you must poll to fill gaps
The same event can arrive more than once, so processing must tolerate duplicates
Events arrive in strict order, so you can rely on sequence numbers
5. What signal tells the platform your endpoint successfully received an event, preventing a retry?
An HTTP 2xx response returned in time
A matching HMAC signature in your response headers
Echoing the event ID back in the body
Closing the TCP connection without any status code
Pre-Reading Check — Platform Specifics
1. What must your endpoint do to complete Slack's one-time URL verification handshake?
Return HTTP 401 to prove it rejects bad requests
Echo the random challenge value back in a 200 response
POST its signing secret back to Slack
Open a WebSocket to Slack's gateway
2. For Slack per-request signing, what is the signature base string that gets HMAC'd?
timestamp + raw_body
The parsed and re-serialized JSON body only
v0:{timestamp}:{raw_body}
The signing secret concatenated with the challenge
3. What is the purpose of Slack's five-minute timestamp check?
To keep clocks synchronized across servers
To reject replayed requests captured and re-sent by an attacker
To limit how many events Slack can send per minute
To delay processing so the queue can catch up
4. How does Discord's Ed25519 scheme differ fundamentally from Slack's and WebEx's HMAC?
It is symmetric, so the same key both signs and verifies
It is asymmetric: Discord signs with a private key, you verify with a public key you cannot forge with
It skips signatures entirely and relies on TLS
It uses HMAC-SHA512 instead of SHA256
5. Why does blindly returning a Discord PONG without verifying signatures fail endpoint registration?
Discord requires the PONG to be signed with your public key
Discord sends deliberately invalid-signature requests and expects a 401 rejection
The PONG body must include the interaction ID
Discord only accepts PONG over a WebSocket, not HTTP
Platform Specifics
Key Points
Slack authenticity rests on a one-time url_verification challenge plus per-request HMAC-SHA256 over v0:{timestamp}:{raw_body}.
Slack's timestamp check (reject if older than five minutes) is built-in replay protection; comparison must be constant-time.
WebEx registers webhooks via API with resource/event filters and signs with HMAC-SHA1 in X-Spark-Signature — same pattern, different hash and header.
Discord uses asymmetric Ed25519 over timestamp + body, requires a 401 on any bad signature, and folds registration into a PING/PONG handshake that tests your rejection path.
The three platforms share the same shape — POST an event, expect a fast 2xx — but differ in their handshake and, crucially, in how they prove authenticity. Slack and WebEx use symmetric HMAC; Discord uses asymmetric Ed25519.
Slack: challenge handshake and signing secret
The URL verification challenge is the handshake. When you first configure your Request URL, Slack sends a one-time POST whose JSON type is url_verification, containing token, challenge, and type. Your endpoint must respond within a few seconds with an HTTP 200 that echoes the challenge back. This proves you control the endpoint and happens once per URL registration.
sequenceDiagram
participant Slack
participant Endpoint as "Your endpoint"
Note over Slack,Endpoint: Once per Request URL registration
Slack->>Endpoint: POST {type: url_verification, token, challenge}
Note over Endpoint: type == url_verification
Endpoint-->>Slack: HTTP 200 echo challenge value
Note over Slack,Endpoint: Request URL now verified; real events begin flowing
The signing secret handles per-request authenticity. Each inbound request carries X-Slack-Request-Timestamp and X-Slack-Signature (of the form v0=<hex_digest>). You reconstruct a base string by concatenating, with literal colons, v0, the timestamp, and the raw, unparsed request body bytes:
Two details are load-bearing. The HMAC must be computed over the raw bytes exactly as received; if a framework parses and re-serializes the JSON, the recomputed signature will not match. And the timestamp check rejects any request more than five minutes from now, defeating replays. hmac.compare_digest compares in constant time, defeating timing attacks.
WebEx: registration and HMAC-SHA1
Cisco WebEx webhooks are created programmatically via the REST API, specifying a resource (such as messages) and an event (such as created), plus a targetUrl. This resource/event filtering means you subscribe only to what you care about. For authenticity, WebEx signs each webhook with HMAC-SHA1 over the raw payload using a secret you supply at registration, delivered in X-Spark-Signature. The logic mirrors Slack's, but uses SHA-1, has no timestamp in the base string, and a differently named header.
Discord: Ed25519 public-key verification
Where Slack and WebEx use a symmetric secret, Discord uses asymmetric Ed25519 public-key signatures. Discord holds a private key and signs each interaction; you get only the corresponding public key. You verify signatures but can never forge one — a leaked public key does not let an attacker impersonate Discord. Think shared house key (HMAC) versus tamper-evident wax seal (Ed25519).
Each request carries X-Signature-Ed25519 and X-Signature-Timestamp. The signed message is the concatenation timestamp + body:
Discord's handshake is folded into verification: on first saving an Interactions Endpoint URL, Discord sends a PING (type == 1); you must verify the signature and respond 200 with {"type": 1} (a PONG). Critically, Discord also sends deliberately invalid signatures to confirm you reject them with 401 — so a receiver that blindly returns PONG without verifying will fail registration.
Figure 5.1: Per-platform webhook comparison.
Aspect
Slack Events API
Discord Interactions
Cisco WebEx
Signature scheme
HMAC-SHA256 (symmetric)
Ed25519 (asymmetric)
HMAC-SHA1 (symmetric)
Secret you hold
Signing secret
Public key (verify only)
Webhook secret
Signature header
X-Slack-Signature (v0=...)
X-Signature-Ed25519
X-Spark-Signature
Timestamp header
X-Slack-Request-Timestamp
X-Signature-Timestamp
(none in base string)
Signed message
v0:{ts}:{raw_body}
{ts}{raw_body}
raw_body
Handshake
url_verification echoes challenge
PING type 1 → PONG{"type":1}
(API registration)
Reject bad request
(drop / 4xx)
HTTP 401 required
(drop / 4xx)
Ack deadline
~3 seconds
~3 seconds
fast 2xx
Visual animation — coming soon
Post-Reading Check — Platform Specifics
1. What must your endpoint do to complete Slack's one-time URL verification handshake?
Return HTTP 401 to prove it rejects bad requests
Echo the random challenge value back in a 200 response
POST its signing secret back to Slack
Open a WebSocket to Slack's gateway
2. For Slack per-request signing, what is the signature base string that gets HMAC'd?
timestamp + raw_body
The parsed and re-serialized JSON body only
v0:{timestamp}:{raw_body}
The signing secret concatenated with the challenge
3. What is the purpose of Slack's five-minute timestamp check?
To keep clocks synchronized across servers
To reject replayed requests captured and re-sent by an attacker
To limit how many events Slack can send per minute
To delay processing so the queue can catch up
4. How does Discord's Ed25519 scheme differ fundamentally from Slack's and WebEx's HMAC?
It is symmetric, so the same key both signs and verifies
It is asymmetric: Discord signs with a private key, you verify with a public key you cannot forge with
It skips signatures entirely and relies on TLS
It uses HMAC-SHA512 instead of SHA256
5. Why does blindly returning a Discord PONG without verifying signatures fail endpoint registration?
Discord requires the PONG to be signed with your public key
Discord sends deliberately invalid-signature requests and expects a 401 rejection
The PONG body must include the interaction ID
Discord only accepts PONG over a WebSocket, not HTTP
Pre-Reading Check — Acknowledgement Deadlines
1. Why does missing the ~3-second deadline cause a retry even if you processed the event?
The platform's HTTP client times out and treats the delivery as failed
The event's signature expires after three seconds
Your queue automatically discards slow events
TLS sessions cannot last longer than three seconds
2. Which four cheap steps does a well-behaved fast-ack handler do inline?
Call the LLM, write records, hit downstream APIs, then respond
Read the raw body, verify signature/timestamp, persist or enqueue, return 2xx
Parse JSON, log the full payload, sleep, then respond
Open a database transaction, run the AI, commit, close
3. Why is it essential that the deferred-work queue be durable?
Durable queues respond faster to the platform
If the worker crashes, the event is not lost — it remains in the queue
Durability lets the web tier skip signature verification
Only durable queues can hold JSON payloads
4. What is an idempotency key and where does it come from?
A secret you generate to sign responses
A stable per-event identifier the platform sends (e.g., Slack event_id) that stays constant across retries
The TLS session ID of the inbound connection
A random UUID your worker assigns after processing
5. When you detect a duplicate event via its idempotency key, what should you return?
HTTP 409 Conflict so the platform stops sending it
HTTP 500 so the platform logs the duplicate
A 2xx, skipping reprocessing, so the platform stops retrying
Nothing — drop the connection silently
Acknowledgement Deadlines
Key Points
Slack and Discord demand a 2xx within roughly three seconds; past the ceiling the client times out and retries even if you actually processed the event.
The fast-ack pattern does only read-verify-enqueue-respond inline and returns 202 Accepted before any slow work.
Defer slow work to a durable queue so a worker crash cannot lose events; this decouples a fast web tier from a slow, independently scalable worker tier.
Use each event's stable idempotency key with an atomic check-and-insert, always return 2xx on duplicates, and set the dedup TTL longer than the platform's full retry window.
The 3-second rule and fast-ack
Slack's Events API and Discord's interactions both require an HTTP 2xx within roughly 3 seconds; others document 5–30 second windows (GitHub allows about 10). The deadline exists because the platform's HTTP client will not wait forever; past the ceiling it times out, treats the delivery as failed even if you processed it, and retries.
This is why real receivers do only the minimum inline — the fast-ack pattern: (1) read the raw body, (2) verify the signature and timestamp, (3) persist durably or enqueue, and (4) return 200/202 right away. It does not call an LLM, write large records, or hit downstream APIs inline. Returning 202 Accepted is semantically honest: "I have accepted this for processing," not "I have finished." Discord offers an in-band version — a "deferred" response (type 5) that acknowledges now and lets you edit the reply later.
Handing work to a durable queue
The critical word is durable: persisting the event to a durable queue before you ack means that if your worker crashes, the event is still in the queue. Options include Redis Streams, RabbitMQ, AWS SQS, Kafka, Celery/RQ with a broker, or a database "outbox" table.
Figure 5.5: Fast-ack, then enqueue — a fast web tier decoupled from a slow worker tier.
flowchart LR
P["Platform"] -->|"HTTP POST"| W["Web receiver"]
W -->|"verify + enqueue"| Q[("Durable queue Redis / SQS / RabbitMQ")]
W -.->|"202 Accepted (< 3s)"| P
Q --> B["Background worker LLM calls, DB writes, retries"]
Visual animation — coming soon
This decoupling is the single most important design decision for a multi-channel gateway. The web tier's only job is to be fast and reliable; the worker tier absorbs latency, spikes, and failures. If a worker crashes mid-task, the queue's own retry and visibility-timeout machinery re-processes the message without the platform ever knowing, and you can scale the slow AI logic separately from the fast HTTP layer.
Idempotency keys and deduplication
At-least-once delivery makes duplicates inevitable. Since exactly-once delivery is unattainable, you engineer idempotent processing: processing the same event twice yields the same result as once. The tool is the idempotency key — a stable identifier constant across retries: Slack's event_id, Discord's interaction ID, Stripe's event.id. Before processing, you atomically check whether you have seen that key (via INSERT ... ON CONFLICT DO NOTHING, Redis SETNX, or a unique constraint). If new, process and record; if a duplicate, skip and still return 2xx so the platform stops retrying.
One constraint is easy to overlook: the dedup store's TTL must outlive the platform's entire retry window. Slack retries over roughly five minutes, but others retry for hours or days; if a dedup entry expires before the last retry, that late retry slips past the empty check and is processed twice.
Post-Reading Check — Acknowledgement Deadlines
1. Why does missing the ~3-second deadline cause a retry even if you processed the event?
The platform's HTTP client times out and treats the delivery as failed
The event's signature expires after three seconds
Your queue automatically discards slow events
TLS sessions cannot last longer than three seconds
2. Which four cheap steps does a well-behaved fast-ack handler do inline?
Call the LLM, write records, hit downstream APIs, then respond
Read the raw body, verify signature/timestamp, persist or enqueue, return 2xx
Parse JSON, log the full payload, sleep, then respond
Open a database transaction, run the AI, commit, close
3. Why is it essential that the deferred-work queue be durable?
Durable queues respond faster to the platform
If the worker crashes, the event is not lost — it remains in the queue
Durability lets the web tier skip signature verification
Only durable queues can hold JSON payloads
4. What is an idempotency key and where does it come from?
A secret you generate to sign responses
A stable per-event identifier the platform sends (e.g., Slack event_id) that stays constant across retries
The TLS session ID of the inbound connection
A random UUID your worker assigns after processing
5. When you detect a duplicate event via its idempotency key, what should you return?
HTTP 409 Conflict so the platform stops sending it
HTTP 500 so the platform logs the duplicate
A 2xx, skipping reprocessing, so the platform stops retrying
Nothing — drop the connection silently
Pre-Reading Check — Building a Robust Receiver
1. What is the fixed end-to-end sequence a unified receiver executes inside the few-second budget?
Parse JSON, call the LLM, verify, respond
Capture raw body, verify, handle handshake, dedup, enqueue, ack 202
Enqueue first, then verify the signature in the worker
Log the payload, sleep, verify, then process synchronously
2. Why must you capture await request.body() first and never verify against await request.json()?
JSON parsing is too slow for the deadline
Re-parsed JSON can reorder keys or change whitespace, breaking the raw-body signature
request.json() is unavailable in FastAPI
The body can only be read once, so JSON must come first
3. What three universal rules apply to every platform's verifier?
Parse JSON, log secrets, and retry on failure
Use raw bytes, constant-time HMAC comparison, and fail closed (reject the unverifiable)
Cache signatures, respond 500 on error, and skip the timestamp
Trust TLS, accept any 2xx, and normalize casing only
4. What does it mean to "fail closed" in signature verification?
Close the TCP connection after every request
A request that cannot be authenticated is rejected, never processed
Process the request but log a warning
Retry verification until it eventually passes
5. What should structured logging at ingress record — and never record?
Record the signing secret and raw signatures for debugging
Record platform, idempotency key, verification result, dedup outcome, ack latency — never secrets or signatures
Record only free-text lines with no structure
Record the full message content and nothing else
Building a Robust Receiver
Key Points
A unified receiver gives each platform its own route sharing one skeleton: capture raw body, verify, handle handshake, dedup, enqueue, ack 202.
Capture await request.body() first; verifying against re-parsed JSON breaks the raw-body signature.
Every verifier obeys three rules: operate on raw bytes, compare HMACs in constant time, and fail closed.
Instrument the ingress route with structured logs of platform, idempotency key, verification result, dedup outcome, and ack latency — never secrets or signatures.
The end-to-end flow is a fixed sequence executed inside the few-second budget: receive POST → capture raw body → verify signature and timestamp (reject if bad) → extract the idempotency key → INSERT it, skipping if present → enqueue → return 202. A per-platform route lets each endpoint pull the right headers and dispatch to the right verifier:
from fastapi import FastAPI, Request, Response, HTTPException
app = FastAPI()
@app.post("/webhooks/slack")
async def slack_webhook(request: Request):
raw = await request.body() # RAW bytes, before JSON parsing
if not verify_slack(request.headers, raw, SLACK_SIGNING_SECRET):
raise HTTPException(status_code=401)
payload = await request.json()
if payload.get("type") == "url_verification": # one-time handshake
return {"challenge": payload["challenge"]}
if already_seen(payload["event_id"]): # dedup
return Response(status_code=202)
await enqueue(payload) # durable queue
return Response(status_code=202) # fast-ack
The Discord route follows the same skeleton but verifies with Ed25519 and answers the PING with a PONG:
@app.post("/webhooks/discord")
async def discord_webhook(request: Request):
raw = await request.body()
sig = request.headers["X-Signature-Ed25519"]
ts = request.headers["X-Signature-Timestamp"]
if not verify_discord(sig, ts, raw.decode()):
raise HTTPException(status_code=401) # required by Discord
payload = await request.json()
if payload["type"] == 1: # PING
return {"type": 1} # PONG
if already_seen(payload["id"]):
return Response(status_code=202)
await enqueue(payload)
return Response(status_code=202)
The essential structural point: capture await request.body()first, and never verify against await request.json(), because re-parsed JSON breaks the raw-body signature on both platforms.
Verifying signatures per platform
The dispatch table is the heart of the receiver: getting any cell wrong means rejecting genuine traffic or, worse, accepting forged traffic. Each platform contributes its verify_* function, but all three share non-negotiable rules: operate on raw bytes, use constant-time comparison where HMAC is involved, and fail closed — an unverifiable request is rejected, never processed. Also note HTTP header names are case-insensitive; FastAPI's request.headers already handles this, which suits a multi-platform ingress.
flowchart TD
A["Inbound POST"] --> B["Capture raw body bytes"]
B --> C{"Platform?"}
C -->|"Slack"| D["HMAC-SHA256 over v0:ts:body + 5-min replay window"]
C -->|"WebEx"| E["HMAC-SHA1 over raw body"]
C -->|"Discord"| F["Ed25519 verify over ts+body with public key"]
D --> G{"Signature valid (constant-time)?"}
E --> G
F --> G
G -->|"No"| H["Reject: 401 / 4xx (fail closed)"]
G -->|"Yes"| I["Proceed: handshake, dedup, enqueue, ack"]
Structured logging and observability at ingress
The webhook route is the front door of the gateway, which makes it the best place to observe. Because every event passes through, structured logging at ingress — machine-parseable key/value records — lets you answer operational questions later: which platform, was the signature valid, was it a duplicate, how long did the ack take. At minimum log the platform, the event/idempotency key, the verification outcome, dedup vs fresh enqueue, and the response latency. Crucially, never log the signing secret or raw signatures, and treat message content as sensitive. This visibility turns "duplicates are expected" and "acks must be fast" into things you can measure — for example, alerting when ack latency creeps toward the three-second ceiling.
Post-Reading Check — Building a Robust Receiver
1. What is the fixed end-to-end sequence a unified receiver executes inside the few-second budget?
Parse JSON, call the LLM, verify, respond
Capture raw body, verify, handle handshake, dedup, enqueue, ack 202
Enqueue first, then verify the signature in the worker
Log the payload, sleep, verify, then process synchronously
2. Why must you capture await request.body() first and never verify against await request.json()?
JSON parsing is too slow for the deadline
Re-parsed JSON can reorder keys or change whitespace, breaking the raw-body signature
request.json() is unavailable in FastAPI
The body can only be read once, so JSON must come first
3. What three universal rules apply to every platform's verifier?
Parse JSON, log secrets, and retry on failure
Use raw bytes, constant-time HMAC comparison, and fail closed (reject the unverifiable)
Cache signatures, respond 500 on error, and skip the timestamp
Trust TLS, accept any 2xx, and normalize casing only
4. What does it mean to "fail closed" in signature verification?
Close the TCP connection after every request
A request that cannot be authenticated is rejected, never processed
Process the request but log a warning
Retry verification until it eventually passes
5. What should structured logging at ingress record — and never record?
Record the signing secret and raw signatures for debugging
Record platform, idempotency key, verification result, dedup outcome, ack latency — never secrets or signatures