casehub-connectors has always been outbound-only — send a message to Slack, Teams, SMS, WhatsApp. The library routes to the right connector by id and that’s it. I added inbound this week: receiving messages from the same platforms via webhook.

The first design question was whether to have one InboundConnector interface covering both pull-based transports (IMAP polling) and webhook-based ones (Slack Events API, Teams, WhatsApp, Twilio). A unified interface would have felt symmetric. It was also wrong.

Webhook connectors have no lifecycle. They don’t start or stop — the JAX-RS endpoint just exists and receives POST requests. Pull connectors do have a lifecycle: start(sink) is called at Quarkus startup, the connector begins polling, and stop() is called at shutdown. Putting start() and stop() on a unified interface and then making webhook connectors implement them as final no-ops means InboundConnectorService.onStart() would call a no-op on every webhook connector at startup. The interface would be misleading to anyone reading it. So there are two types: InboundConnector for pull, WebhookInboundConnector standalone for webhook. CDI discovers them through separate @All List<> injection points.

The router maps each result type to an HTTP response. Unauthorized was the interesting one. My first instinct was to return 401 — the request failed authentication, 401 is what 401 means. But all four platforms (Slack, Twilio, WhatsApp, Teams) retry on non-2xx. Slack retries for up to 30 days with exponential backoff. So a bad signature on a POST becomes a retry storm if you return 401. The correct response for a POST is 200 with a security log entry — the platform never knows you rejected it; the ops team sees it in the logs.

GET is different. WhatsApp’s subscription verification is a human-triggered one-time action in Meta’s admin console. If the verify token is wrong, a 200 response with a body that doesn’t match the expected challenge just silently fails the subscription — the admin sees a green 200 but no webhooks arrive. They need the 403. So: POST Unauthorized → 200, GET Unauthorized → 403. The router checks request.method() in the Unauthorized case.

Slack has one more wrinkle. Before the Events API goes live, Slack sends a url_verification POST with a challenge value. You respond with the challenge, Slack confirms the URL is reachable, and only then can you configure the signing secret. That means url_verification has to be handled before the blank-secret guard — if you check the secret first and return Ignored() when it’s not configured, initial setup breaks every time. The check order in SlackInboundConnector is: URL verification, then blank-secret, then replay prevention, then HMAC.

I brought Claude in for two rounds of code review — one on the spec, one on the implementation. The spec review was thorough: 14 findings, three of them critical. The most important was that the spec never mentioned constant-time HMAC comparison. String.equals() is timing-attackable — an attacker can measure response latency to recover the expected HMAC byte by byte. The fix is MessageDigest.isEqual(). The implementation review caught the replay window boundary: the spec said “more than 5 minutes” but the code used > at exactly 300 seconds, which lets 300-second-old requests through. Should be >=.

We ended up with four concrete webhook connectors — Slack (HMAC-SHA256), Teams Outgoing Webhooks (HMAC-SHA256 with a base64-decoded key), WhatsApp Business (HMAC-SHA256 + GET challenge), and Twilio SMS (HMAC-SHA1 over the full request URL and sorted form params, because Twilio chose SHA-1 and it’s not configurable). One new Maven module, one JAX-RS router, and a CDI event bus that both pull and webhook paths route through.


<
Previous Post
The Audit Entry That Couldn't Exist
>
Next Post
The string that looked like a string