By this point in the series we’ve accomplished something pretty satisfying. A customer opens a support conversation in your application. ColdFusion stores it. Your application posts it into Slack. Your support team replies from Slack without ever opening your application.
From the perspective of your support staff, life is good. From the perspective of your customer though, nothing has happened. That’s because Slack knows someone replied. Your application doesn’t.
One of the biggest mental shifts when building event-driven systems is realizing that receiving an event is not the same thing as updating your application’s state. Those are completely separate responsibilities.
Slack’s job is simply to tell you, “Something happened.” Everything after that is your responsibility. You decide whether the event matters. You decide what object it belongs to. You decide who said it. You decide whether it should become part of a customer conversation.
Until your application makes those decisions, all you’ve received is another JSON document. In this article we’re finally completing the loop. Slack replies are going to become real support messages. Those messages are going to become customer-visible conversation history. And within a fraction of a second, they’re going to appear in the customer’s browser without refreshing the page.
This is where a webhook becomes a conversation.
Where We Left Off
At the end of Part 6 our architecture looked something like this.
Customer
│
▼
Support Conversation
│
▼
Database
│
▼
Slack API
│
▼
Support ChannelCommunication was flowing in one direction. Customers could talk to support. Support could talk to Slack. That’s it.
Slack replies were arriving at our Events endpoint, being verified, written into our inbox table, and then patiently waiting for someone to care about them, which is exactly what we wanted. Remember one of the recurring themes throughout this series: Never perform real work inside a webhook. The webhook exists to receive events safely. Business logic happens somewhere else.
That somewhere else is today’s topic.
Meet slack_event_processor.cfc
Every application eventually develops a component that quietly does all of the boring work nobody notices until it’s missing. For us, that’s slack_event_processor.cfc. It has one job: Take an unprocessed Slack event and determine whether it represents something meaningful inside our application.
Notice what it does not do. It doesn’t know HTTP, or webhooks, or Slack signatures, or retries. All of that has already happened. Its world begins with a database row.
Slack
│
Webhook
│
Inbox Table
│
▼
slack_event_processor.cfcThis separation turns out to be incredibly valuable, because now the processor can be executed from:
- a scheduled task
- a background queue
- a command-line tool
- unit tests
- retry logic
...without caring where the event originally came from. That’s one of those design decisions that doesn’t seem important until six months later when it saves you an entire weekend.
Processing the Queue
Instead of asking Slack to wait while we process everything, we periodically ask our inbox table a much simpler question; “Do you have anything new for me?”
Conceptually it looks something like this.
<cfscript>
var events = slackInbox.getPendingEvents();
for (var event in events) {
slackEventProcessor.process(event);
}
</cfscript>The real implementation is naturally more defensive. Rows are locked. Failures are logged. Retries are limited. Successful events are marked complete. But the overall idea stays refreshingly boring. And boring is exactly what you want from background processing.
Not Every Slack Event Deserves Your Attention
Slack generates an astonishing number of event types.
- Messages
- Edits
- Deletes
- Emoji reactions
- Pins
- Unpins
- Users joining channels
- Users leaving channels
- Typing indicators
- Probably someone’s refrigerator reporting low milk
Your support system cares about almost none of them. One of the earliest exits inside the processor simply ignores anything that isn’t relevant.
if (event.type != "message") {
return;
}This seems almost too simple, until Slack adds five new event types next month. Ignoring what you don’t care about is every bit as important as processing what you do. Event processors become dramatically easier to reason about when every unsupported event exits immediately.
Bots Shouldn’t Have Conversations With Themselves
The second filter is just as important. Ignore bot messages. Slack bots frequently create messages. Your own application certainly does. If you don’t filter them out you’ll eventually create an entertaining but disastrous feedback loop.
Imagine this:
- Your application posts into Slack.
- Slack notifies your application.
- Your application saves the message.
- Maybe it republishes it.
- Slack receives another message.
- Slack notifies your application.
Repeat until someone notices CPU usage approaching orbit. It's the modern day equivalent of halt and catch fire.
Instead, the processor simply refuses to process bot-generated content. Conceptually:
if (structKeyExists(message, "bot_id")) {
return;
}Or perhaps:
if (message.subtype == "bot_message") {
return;
}The exact implementation depends on which Slack events you’ve subscribed to, but the principle never changes. Humans create support replies. Bots generally create infrastructure. Treat them differently.
A Slack Message Still Isn’t a Customer Conversation
Now we reach the interesting part. Suppose we receive this event:
{
"channel": "C123456",
"thread_ts": "1751032712.003900",
"user": "U094832",
"text": "Can you try clearing your browser cache?"
}What customer is that for? Slack has no idea. It knows channels. Threads. Users. Timestamps.
Your application knows customers.
Those are completely different domains. Fortunately, we’ve been preparing for this since earlier in the series. When the original support request was posted into Slack, we stored the Slack thread timestamp alongside the support conversation. That thread timestamp effectively becomes our foreign key back into the application.
Slack Thread
│
thread_ts
│
▼
Support ConversationSo our lookup becomes pleasantly simple.
var conversation = supportConversationService.getBySlackThread( event.thread_ts );If nothing matches, ignore it. Not every Slack thread belongs to your application. Your support team is probably discussing deployment plans, lunch, and whether production really needed that Friday afternoon hotfix. Those conversations should remain exactly where they belong.
Resolving the Support Representative
The customer doesn’t care that Slack user U094832 replied. They care that Sarah replied. Or David. Or Chris. Slack user IDs are infrastructure. Your application needs real people. That means resolving Slack identities back into internal support representatives.
Slack User
│
▼
Internal User
│
▼
Support MessageThis is one of those places where it’s tempting to take a shortcut. You might think, “Slack already gives me the user’s name. Why not just store that?”
Because names change.
People get married. They change their display name. They decide they’re now “Dave” instead of “David”. Someone joins the team with the same first name. Slack lets users change all sorts of profile information whenever they feel like it, which is great for humans, but terrible for databases.
The one thing that doesn’t change is the Slack user ID.
When someone authorizes your Slack app, it’s worth capturing that permanent Slack user ID and associating it with the corresponding support representative in your own database. From that point forward, every incoming Slack event can be resolved back to the correct person, regardless of what they’ve decided to call themselves this week.
Typically that lookup is as simple as maintaining a mapping table.
var representative = supportRepresentativeService.getBySlackUserId( event.user );Once that lookup succeeds, something interesting happens. We’re finished thinking in terms of Slack. We no longer care about channels, users, or event payloads. We now have an internal support representative, and from this point forward the rest of the application works entirely in terms of its own domain model.
If no representative exists, you’ve got another decision to make. Reject the message? Create a placeholder user? Log an error? Every application answers that differently.
Personally, I prefer failing loudly. Silent failures are wonderful at remaining undiscovered until the CEO decides to test the support system.
Persisting the Reply
Up until this point, we’ve been working entirely with Slack concepts: users, thread timestamps, event payloads, and message metadata. Once we’ve identified the conversation and the support representative, all of that disappears. From here on out, we’re back in our own domain. That’s an important transition. The rest of the application doesn’t care that the reply originated in Slack. As far as it’s concerned, a support representative replied to a customer.
Once we’ve identified the conversation, the representative, and the message text, the remaining work feels remarkably ordinary. We’re simply inserting another support message.
supportMessageService.create(
conversationId = conversation.getId(),
senderType = "support",
senderId = representative.getId(),
message = event.text,
slackTs = event.ts
);Notice something subtle. We’re not storing “a Slack message.” We’re storing a support reply. Slack has disappeared entirely from the domain model. That’s intentional. Tomorrow you might replace Slack. Or integrate Microsoft Teams. Or Discord. Or carrier pigeons.
The rest of your application shouldn’t notice. Slack is just another transport. If you ever decide to replace Slack with Teams, Discord, or whatever collaboration platform is fashionable next year, your domain model shouldn’t need to care.
Duplicate Delivery Happens
Eventually Slack retries a webhook. Maybe your server responded slowly or there was a transient network problem. Maybe the internet simply decided today should be "interesting."
Duplicate events are perfectly normal. Duplicate support replies are not. Fortunately Slack timestamps are unique enough to make excellent deduplication keys.
if ( supportMessageService.existsBySlackTs( event.ts ) ) {
return;
}One tiny guard clause, avoiding potentially thousands of duplicated messages. Never assume webhook providers deliver exactly once. Almost none of them do.
Telling the Browser
Now for the satisfying part. The database contains the reply, but the customer still can’t see it. We need one final hop.
Earlier in the series we introduced Server-Sent Events (SSE). Instead of asking the browser to poll every few seconds, the server simply announces when something changes. Conceptually:
Slack
│
▼
Event Processor
│
▼
Database
│
▼
Server-Sent Event
│
▼
BrowserNotice that we’re not pushing the message itself across the SSE connection. We’re simply notifying the browser that something changed. The browser can then request the latest conversation state using the same API endpoints it already knows about. This keeps the SSE payloads tiny and avoids duplicating serialization logic.
After persisting the support reply, the processor publishes a notification.
sse.publish(
channel = conversation.getId(),
event = {
type = "support_reply",
messageId = message.getId()
}
);The browser is already listening. It receives the event, fetches the new message, and updates the conversation. The customer never refreshes the page. The support representative never opens your application.
Both sides simply continue talking. That’s the point where it finally feels like chat instead of email with better marketing.
End-to-End Architecture
By now our support pipeline looks considerably more interesting.
Customer
│
▼
Support Conversation
│
▼
Database
│
▼
Outbound Slack API
│
▼
Slack Support Thread
│
Support Representative
│
▼
Slack Events Webhook
│
Signature Verification
│
▼
Slack Event Inbox
│
▼
slack_event_processor.cfc
│
┌───────────────┼────────────────┐
│ │ │
Ignore Bots Ignore Noise Ignore Unknown Threads
│ │ │
└───────────────┴────────────────┘
│
▼
Resolve Conversation + Representative
│
▼
Persist Support Reply
│
▼
Publish Server-Sent Event
│
▼
Browser UpdatesNotice how little the webhook actually does. The webhook accepts data. Everything interesting happens afterward. That’s exactly where you want complexity to live.
Why This Architecture Ages Well
One of my favourite aspects of this design is that every stage has a single responsibility.
- The webhook receives events.
- The inbox stores events.
- The processor interprets events.
- The support service persists replies.
- The SSE publisher notifies browsers.
No layer needs to understand the entire workflow. Each component only needs enough knowledge to perform its own job, which dramatically reduces coupling. It also makes debugging wonderfully straightforward. If a customer reports that replies aren’t appearing, you can ask questions in sequence.
- Did Slack deliver the event?
- Did the inbox receive it?
- Did the processor execute?
- Was the conversation found?
- Was the reply persisted?
- Was the SSE event published?
- Where did the chain stop?
- Is it lunch time yet?
You don’t have one giant mysterious “support system," you have several tiny systems connected together. Those are much easier to reason about.
What We’ve Built
Across the last seven articles we’ve quietly assembled something much more capable than a webhook integration. We’ve built a resilient messaging pipeline. Customers communicate through your application. Support staff communicate through Slack. Both sides remain blissfully unaware that they’re using different systems. Messages survive retries. Processing is asynchronous. Failures can be retried. Replies appear in real time.
Most importantly, every message eventually becomes part of a durable customer conversation rather than disappearing into Slack history forever.
That’s a significant architectural milestone.
The Biggest Lessons
If there’s one idea I hope sticks after this article, it’s this: Events are observations, not business objects. Slack observed that somebody replied. Your application decided that reply belonged to a customer conversation. Those are different responsibilities.
Along the way we also reinforced a few themes that have been showing up throughout this series.
- Webhooks should acknowledge quickly and defer real work.
- Queue first. Process later.
- Filter aggressively.
- Store provider identifiers, but don’t let them leak into your domain model.
- Deduplicate everything.
- Keep transports separate from business logic.
- Push updates to the browser instead of forcing users to refresh.
None of those lessons are unique to Slack. They’re useful for almost every event-driven integration you’ll ever build.
Next Time
Our support chat now feels alive. Messages move from browser to Slack. Replies move from Slack back into the browser. The entire conversation stays synchronized.
There’s only one problem.
It’s assuming everything goes perfectly. Real systems don’t.
- Networks fail.
- Slack retries.
- Jobs crash halfway through processing.
- Background workers restart.
- Customers somehow manage to click the same button four times in under a second.
In Part 8 we’re going to make the entire pipeline resilient. We’ll look at retries, idempotency, poison messages, dead-letter queues, operational visibility, and the kinds of defensive programming techniques that let you sleep through the night instead of wondering whether a webhook quietly failed three hours ago. Because building integrations is fun. Keeping them running is where the real engineering begins.
Our Slack integration works. In Part 8, we’re going to make sure it keeps working when the real world inevitably gets involved.