Building a Slack-Backed Support Chat in ColdFusion - Part 5: Posting ColdFusion Messages into Slack Threads

Your First Conversation with Slack

At long last... we get to write some ColdFusion. The architecture is finished. The database exists. Slack is configured. The bot has permissions. The Events API is patiently waiting for us. It’s finally time to make the monkey push the button.

Oddly enough, this ends up being one of the shortest parts of the project. Not because it isn’t important, but because we spent the previous four articles making it simple.

The Happy Path Is Almost Boring

Sending a message to Slack is refreshingly straightforward. Build some JSON. POST it to chat.postMessage. Inspect the response. Go home feeling like a genius.

Conceptually, it looks like this.

Browser
    │
    ▼
ColdFusion
    │
chat.postMessage
    │
    ▼
Slack

That’s really all there is. The complexity comes from everything surrounding that API call.

Creating the Conversation

Remember from Part 3 where every conversation has its own identity? When the visitor sends their very first message, we create the conversation before we even think about Slack.

Conversation

conversation_id
visitor_name
visitor_email
status = Active
created_date

Then we save the customer’s message. Only after both of those succeed do we contact Slack. That ordering matters. If Slack disappears for five minutes, your application should still know the conversation exists. Your database is the source of truth. Slack is simply another participant.

Finally, some CFML

Let's start with a basic component we'll call slack.cfc.

This gives us a clean home for the Slack integration instead of scattering cfhttp calls all over the application like a raccoon got into the source code. At this point we only need two properties: the bot token and the default channel. The bot token identifies the Slack bot that will send messages, and the default channel gives the component somewhere to post when a specific channel is not provided.

It is deliberately small. That is the point. We are setting up the pieces we already know we need without pretending we can predict every future Slack feature we'll eventually add after someone says, "Can we just also..."

component output="false" {

	property name="bot_token";
	property name="default_channel";

	public slack function init(
		required string bot_token,
		required string default_channel
	) {
		variables.bot_token = arguments.bot_token;
		variables.default_channel = arguments.default_channel;

		return this;
	}

}

That is not much code, and that is intentional.

At this stage, the component should only know the things every Slack request will need: the bot token and the default channel. We are not solving every future Slack problem yet. We are just creating a clean integration boundary so the rest of the application does not need to know how Slack works.

The rest of the application should not care about bearer tokens, API URLs, headers, JSON serialization, or Slack’s habit of returning ok: false inside a perfectly successful HTTP response.

That mess belongs here.

Think of this component as the front desk for Slack. Other parts of the application can walk up and say, “Please send this message.” They should not have to know which building Slack lives in, what badge gets them through security, or which elevator smells faintly of cigarettes and regret.

Before we create a post_message function, we need one lower-level helper that every Slack method can share.

	private struct function call_slack_api(
		required string method,
		required struct payload
	) {
		var response = "";

		cfhttp(
			url = "https://slack.com/api/#arguments.method#",
			method = "POST",
			result = "response",
			timeout = 15
		) {
			cfhttpparam(
				type = "header",
				name = "Authorization",
				value = "Bearer #variables.bot_token#"
			);

			cfhttpparam(
				type = "header",
				name = "Content-Type",
				value = "application/json; charset=utf-8"
			);

			cfhttpparam(
				type = "body",
				value = serializeJSON( arguments.payload )
			);
		}

		if ( !isJSON( response.fileContent ) ) {
			throw(
				type = "Slack.InvalidResponse",
				message = "Slack returned a non-JSON response.",
				detail = response.fileContent
			);
		}

		var parsed = deserializeJSON( response.fileContent );

		parsed[ "_http_status_code" ] = response.statusCode;
		parsed[ "_raw_response" ] = response.fileContent;

		return parsed;
	}

This gives us one universal place to talk to Slack.

That matters more than it might seem at first. Every Slack API call needs the same basic plumbing: the API URL, the bearer token, the JSON content type, the serialized payload, response parsing, and error handling when Slack returns something unexpected.

By putting all of that in call_slack_api, every future method can focus on the thing it actually does instead of recreating the same cfhttp ceremony over and over again. Posting messages, updating messages, opening conversations, fetching thread replies... they can all use the same internal helper.

It also gives us one place to improve the integration later. Need better logging? Add it here. Need retry handling? Add it here. Need timing metrics? Add them here. Need to debug why Slack returned something weird at 4:30 on a Friday? This is where you'll be staring while quietly questioning your career choices.

With the generic API helper in place, the first public method becomes pleasantly boring. That is exactly what we want.

The rest of the application should not call chat.postMessage directly. It should call a method that describes what it wants to do: post a message. The component can worry about translating that into Slack’s particular flavour of JSON, including the channel, adding a thread_ts when needed, calling the API, and checking whether Slack actually accepted the message.

This function handles both cases we care about right now. If there is no thread_ts, it posts a new top-level message into the channel. That creates the Slack thread, and the returned ts gets stored on the conversation record. If there is a thread_ts, it posts the message as a reply inside the existing Slack thread.

Same function. Two use cases. Less code. Fewer places for Future You to find something horrifying.

	public struct function post_message(
		required string text,
		string channel = variables.default_channel,
		string thread_ts = ""
	) {
		var payload = {
			"channel" = arguments.channel,
			"text" = arguments.text
		};

		if ( len( trim( arguments.thread_ts ) ) ) {
			payload[ "thread_ts" ] = arguments.thread_ts;
		}

		var result = call_slack_api(
			method = "chat.postMessage",
			payload = payload
		);

		if ( !result.ok ) {
			throw(
				type = "Slack.PostMessageFailed",
				message = "Slack rejected chat.postMessage.",
				detail = serializeJSON( result )
			);
		}

		return result;
	}

Now we've got everything we need for the outbound side.

The public post_message method builds the payload Slack expects, includes thread_ts only when we're replying to an existing thread, sends the request through our shared API helper, and refuses to pretend everything is fine if Slack comes back with ok: false.

That last part matters. A failed Slack response should not look like a successful application operation. If Slack rejects the message, we throw a meaningful exception with the response details so the calling code can log it, retry it, queue it, or otherwise deal with reality instead of wandering happily into production with a false sense of accomplishment.

At this point the component is intentionally simple, but useful. It can send a first message, capture the returned ts, and send future messages into the same thread using thread_ts.

That is enough to build the first half of the bridge.

The Most Important INSERT You’ll Make

Once Slack returns the timestamp, update your conversation record:

conversation_id

slack_channel

slack_thread_ts

Congratulations. You’ve just connected your application’s conversation with Slack’s conversation. From this point forward, every reply knows exactly where it belongs. No searching. No guessing. No trying to match customers by email address because someone forgot to save the thread identifier.

Life is good.

The returned ts is too important to leave sitting in a local variable while we congratulate ourselves. It also doesn’t belong inside slack.cfc. That component should know how to talk to Slack. It shouldn’t know your datasource, your schema, your table names, or even that a database exists. That responsibility belongs to a higher-level service that coordinates the application workflow.

That service posts the first message, confirms Slack accepted it, and immediately stores the returned ts against the conversation as slack_thread_ts.

This separation matters. It keeps slack.cfc reusable while still making the database update part of the operation rather than an optional chore someone might remember later.

Because “we’ll save that important identifier afterwards” is how important identifiers disappear. The critical operation belongs in a conversation service that coordinates both systems:

  1. Call slack.post_message().
  2. Confirm Slack returned ok: true.
  3. Save the returned ts as slack_thread_ts.
  4. Return the Slack result.

Let's whip up a component called conversation_service.cfc to handle that interaction.

component output="false" {
	property name="slack";
	public conversation_service function init(
		required slack slack
	) {
		variables.slack = arguments.slack;
		return this;
	}
	public struct function create_slack_thread(
		required string conversation_id,
		required string message,
		string channel = ""
	) {
		var slack_arguments = {
			text = arguments.message
		};
		if ( len( trim( arguments.channel ) ) ) {
			slack_arguments.channel = arguments.channel;
		}
		var slack_result = variables.slack.post_message(
			argumentCollection = slack_arguments
		);
		queryExecute(
			"
				UPDATE conversation
				SET
					slack_channel = :slack_channel,
					slack_thread_ts = :slack_thread_ts
				WHERE conversation_id = :conversation_id
			",
			{
				slack_channel = {
					value = slack_result.channel,
					cfsqltype = "cf_sql_varchar"
				},
				slack_thread_ts = {
					value = slack_result.ts,
					cfsqltype = "cf_sql_varchar"
				},
				conversation_id = {
					value = arguments.conversation_id,
					cfsqltype = "cf_sql_varchar"
				}
			}
		);
		return slack_result;
	}
}

Using the Component

Calling the component is refreshingly uneventful.

conversation_service.create_slack_thread(
    conversation_id = conversation_id,
    message = "Hi! I need some help."
);

Under the hood, the component builds the payload, calls Slack, validates the response, and returns the result. If Slack creates a new thread, the returned ts becomes the thread_ts you’ll persist against your conversation record.

That’s it.

Every Message After That

Every future message becomes even simpler.

Just include the thread timestamp.

{
	"channel": "C123456",
	"thread_ts": "1751637712.948372",
	"text": "Here's some additional information..."
}

Slack quietly drops the message into the existing thread. Support staff continue the conversation naturally. The customer never knows Slack exists... exactly as intended.

Notice what’s happened over the last twenty minutes. We haven’t just written enough code to send a Slack message. We’ve written enough code that the rest of our application never has to know how Slack works again. From this point forward, every part of the application talks to a clean, well-defined API instead of another SaaS platform. That’s exactly where integrations belong.

However, HTTP 200 Doesn’t Mean Success

This catches almost everybody once. Slack returning HTTP 200 only means Slack successfully received your request. It does not mean Slack accepted it. Always inspect the JSON response.

Good:

{ "ok": true }

Less good:

{ "ok": false, "error": "channel_not_found" }

Or…

{ "ok": false, "error": "not_in_channel" }

Or one of Slack’s many other creative ways of saying, “I’m afraid I can’t do that.” Always check whether that ok attribute is true or false. Always. The HTTP status code only tells half the story.

Your Logs Will Save You

Sooner or later someone will say, “The customer swears they sent a message.” Perfect. Now you can prove it. Every outbound API call should log:

  • Conversation ID
  • Slack channel
  • Thread timestamp
  • Payload
  • Slack response
  • Timestamp
  • Success or failure
  • Retry count

Production debugging becomes dramatically easier when you’re reading facts instead of reconstructing history from memory. Memory is unreliable. Log files are wonderfully stubborn.

Slack Is Somebody Else’s Computer

This is an easy thing to forget... when you call Slack’s API, you’re not calling a function. You’re talking to another computer, across another network, owned by another company, running software you didn’t write.

That’s a lot of opportunities for someone else’s bad day to become your production incident.

Sometimes Slack won't be unavailable. Sometimes it’ll be slow. Sometimes DNS decides today’s the day it wants attention. Sometimes Slack has an outage. Sometimes your ISP has an outage. Sometimes Mercury is apparently in retrograde.

Your application shouldn’t panic every time somebody else’s infrastructure has a bad afternoon.

Queue It

One of the best architectural decisions we made was refusing to make customers wait for Slack. The sequence becomes:

Customer clicks Send
        │
        ▼
Save conversation
        │
        ▼
Save message
        │
        ▼
Return success to browser
        │
        ▼
Queue Slack delivery
        │
        ▼
Background worker posts to Slack

Notice who isn’t waiting anymore? The customer. From their perspective, the message was accepted immediately. If Slack needs another two seconds, that’s your problem. It should never become your customer’s.

Queues make remarkably polite neighbours.

Retries Should Be Intelligent

Some failures deserve another attempt and some don’t.

A temporary network timeout? Retry. A 429 rate limit? Retry after Slack tells you to. An invalid OAuth token? Absolutely do not retry it fifty-seven times hoping Slack changes its mind.

Knowing when not to retry is just as important as knowing when to retry. Otherwise you’re simply automating disappointment.

Idempotency Matters

Distributed systems have an annoying habit of creating uncertainty. Did Slack receive the request? Did the response get lost? Did the network fail halfway through? Nobody knows.

Design your outbound processing so retrying the same operation doesn’t produce duplicate messages. If your worker crashes halfway through delivery, restarting it should be safe. That’s one of those features nobody notices, until the day it saves you.

The Biggest Lesson

The biggest surprise wasn’t Slack. It was discovering how little code actually talks to Slack.

Almost everything we built exists to make that one API call reliable. The actual integration is tiny. The engineering around it is where production software earns its keep. That’s true for Slack. It’s true for Stripe. It’s true for Twilio. It’s true for almost every third-party API you’ll ever integrate with.

The API call is rarely the difficult part. Making it dependable is.

Next Time

So far we’ve only been talking to Slack. Next we’re going to let Slack talk back. We’ll receive Events API callbacks, verify Slack’s request signatures, protect ourselves against replay attacks, handle duplicate event deliveries, and route support replies back into the correct conversation.

Because accepting random HTTP requests at face value has historically been considered poor career planning.