We have spent five articles carefully controlling every request leaving our application. Now we are about to let the internet start making requests into it.
What could possibly go wrong?
Quite a lot, actually. Until now, our application has been in control. It decided when to call Slack. It decided what to send. It decided which channel and thread should receive the message.
The Events API reverses that relationship. Slack decides when something happened. Slack sends an HTTP request to us. Our application has to decide whether the request is legitimate, whether we have seen it before, and what to do with it.
That is a very different trust model.
If someone can convince our endpoint that they are Slack, they can inject fake support replies into customer conversations.
- “Your refund has been approved.”
- “Please send us your banking password.”
- “We have replaced our entire support department with raccoons.”
Two of those would be a serious security incident. The third would at least explain the codebase. Fortunately, Slack expects us to be suspicious.
Slack Doesn't Just "POST Some JSON"
The naive version of an Events API endpoint looks something like this:
Receive request
Deserialize JSON
Process event
Return successThat is also how you build an endpoint that accepts instructions from anybody with your URL. Before touching the JSON, we need to answer a more important question:
Did this request actually come from Slack?
Slack signs each request using the signing secret associated with your app. The request includes an X-Slack-Signature header and an X-Slack-Request-Timestamp header. Your application uses the raw request body, the timestamp, and its copy of the signing secret to independently calculate the expected signature. If the signatures match and the timestamp is recent, the request is authentic.
If they don't, somebody is lying.
The Raw Body Means the Raw Body
This detail matters enough to be annoying. Slack calculates its signature using the exact bytes it sent. Not the deserialized JSON. Not JSON that you parsed and then serialized again. Not the same payload with the keys reordered because your serializer was feeling creative.
The original request body.
These two structures may mean exactly the same thing:
{
"type": "event_callback",
"event_id": "Ev123"
}{"event_id":"Ev123","type":"event_callback"}But in Slack's eyes, they don't produce the same signature. Whitespace matters. Key order matters. Every byte matters. So we capture the raw body first and keep it untouched until after signature verification. This isn't Slack being difficult. This is cryptography being extremely literal.
Why the Timestamp Matters
A valid signature proves the request was signed using our Slack app’s signing secret. That isn't quite enough. Imagine someone records a legitimate request today and sends the exact same request again next week. The signature would still be valid and the body hasn't changed.
That is called a replay attack.
Slack includes the request timestamp in the signature calculation. Before accepting the request, we reject timestamps that differ from the current time by more than five minutes. Slack recommends that five-minute window specifically to protect against replayed requests.
In other words:
Valid signature + recent timestamp = probably Slack
Valid signature + timestamp from last Tuesday = get the hell away from meYour server clock now matters. If it is significantly wrong, valid Slack requests will start failing, so keep your systems synchronized. Time is already complicated enough without your server inventing its own.
Expanding slack.cfc
In Part 5, our component knew about two values:
property name="bot_token";
property name="default_channel";Inbound verification introduces a third:
property name="signing_secret";The bot token authenticates requests we send to Slack. The signing secret verifies requests Slack sends to us. They aren't interchangeable. They solve opposite sides of the integration.
Update the component skeleton:
component output="false" {
property name="bot_token";
property name="default_channel";
property name="signing_secret";
public slack function init(
required string bot_token,
required string default_channel,
required string signing_secret
) {
variables.bot_token = arguments.bot_token;
variables.default_channel = arguments.default_channel;
variables.signing_secret = arguments.signing_secret;
return this;
}
}This is why we created a component instead of dropping isolated cfhttp calls into random templates. The Slack-specific details have one home. Outbound authentication lives here. Inbound verification lives here. The rest of the application remains blissfully unaware of either.
Comparing Secrets Without Being Clever
At the end of the verification process, we need to compare the signature Slack sent with the signature we calculated. A normal string comparison works functionally:
return arguments.expected == arguments.provided;But normal string comparisons may stop as soon as they find a character that doesn't match. That can theoretically leak timing information about how much of the value was correct.
Is someone likely to conduct a sophisticated timing attack against your support chat endpoint? Probably not.
Is a timing-safe comparison difficult to implement? Also no.
Security code isn't the ideal place to say, “Probably fine.” Add this private helper to slack.cfc:
private boolean function secure_compare(
required string expected,
required string provided
) {
if ( len( arguments.expected ) != len( arguments.provided ) ) {
return false;
}
var difference = 0;
for ( var index = 1; index <= len( arguments.expected ); index++ ) {
difference = bitOr(
difference,
bitXor(
asc( mid( arguments.expected, index, 1 ) ),
asc( mid( arguments.provided, index, 1 ) )
)
);
}
return difference == 0;
}The loop evaluates every character instead of stopping at the first mismatch. If every character matches, difference remains zero. If anything differs, it doesn't. It isn't glamorous. That is becoming something of a theme.
Verifying the Slack Request
Now we can build the public verification method. Slack constructs a base string using:
v0:{timestamp}:{raw request body}It signs that string using HMAC SHA-256 and prefixes the resulting hexadecimal digest with v0=. Our implementation performs the same calculation:
public boolean function verify_request(
required string raw_body,
required string timestamp,
required string signature,
numeric max_age_seconds = 300
) {
if (
!len( trim( arguments.timestamp ) )
|| !len( trim( arguments.signature ) )
) {
return false;
}
if ( !isNumeric( arguments.timestamp ) ) {
return false;
}
var current_epoch = createObject(
"java",
"java.time.Instant"
).now().getEpochSecond();
if (
abs( current_epoch - val( arguments.timestamp ) )
> arguments.max_age_seconds
) {
return false;
}
var signature_base = "v0:#arguments.timestamp#:#arguments.raw_body#";
var expected_signature = "v0=" & lCase(
hmac(
signature_base,
variables.signing_secret,
"HmacSHA256",
"UTF-8"
)
);
return secure_compare(
expected = expected_signature,
provided = lCase( arguments.signature )
);
}The method deliberately returns a boolean. Its job is to answer one question, "Is this request authentic and recent?" It doesn't parse events, or update conversations or send browser notifications.
It verifies Slack. One function. One responsibility. Fewer opportunities for Future You to discover that a security method also sends email for reasons nobody remembers.
The First Request Is a Challenge
When you enter an Events API Request URL in Slack, Slack doesn't immediately trust that you own it. Reasonable. The first request has a payload resembling this:
{
"type": "url_verification",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMhkvFXO7tYXAYM8P"
}Your endpoint verifies the request signature and returns the challenge value. That is all. No OAuth dance or complicated handshake or ceremonial exchange of certificates beneath a full moon.
Slack sends a value. You send it back. Slack officially believes you can receive HTTP requests.
Creating the Events Endpoint
We need a public URL that Slack can call. For the generic repository artifact, we will use:
/slack/events.cfm. A routed application could map this logic through a controller instead. The surrounding framework doesn't change the important parts. The endpoint must:
- Capture the raw request body.
- Read Slack’s signature headers.
- Verify the request.
- Parse the JSON only after verification.
- Respond to URL verification challenges.
- Store valid events for later processing.
- Return quickly.
Here is the first version:
<cfscript>
request_data = getHttpRequestData();
raw_body = isBinary( request_data.content )
? charsetEncode( request_data.content, "UTF-8" )
: request_data.content;
slack_signature = request_data.headers[ "X-Slack-Signature" ] ?: "";
slack_timestamp = request_data.headers[ "X-Slack-Request-Timestamp" ] ?: "";
if (
!application.slack.verify_request(
raw_body = raw_body,
timestamp = slack_timestamp,
signature = slack_signature
)
) {
cfheader(
statuscode = 401,
statustext = "Unauthorized"
);
cfcontent(
type = "application/json",
variable = charsetDecode(
serializeJSON( { "ok" = false } ),
"UTF-8"
)
);
abort;
}
if ( !isJSON( raw_body ) ) {
cfheader(
statuscode = 400,
statustext = "Bad Request"
);
cfcontent(
type = "application/json",
variable = charsetDecode(
serializeJSON( { "ok" = false } ),
"UTF-8"
)
);
abort;
}
payload = deserializeJSON( raw_body );
if (
payload.type == "url_verification"
&& structKeyExists( payload, "challenge" )
) {
cfcontent(
type = "text/plain; charset=utf-8",
variable = charsetDecode(
payload.challenge,
"UTF-8"
)
);
abort;
}
if ( payload.type == "event_callback" ) {
application.slack_event_service.enqueue_event(
payload = payload,
raw_body = raw_body,
retry_number = request_data.headers[ "X-Slack-Retry-Num" ] ?: "",
retry_reason = request_data.headers[ "X-Slack-Retry-Reason" ] ?: ""
);
}
cfcontent(
type = "application/json",
variable = charsetDecode(
serializeJSON( { "ok" = true } ),
"UTF-8"
)
);
</cfscript>This endpoint is intentionally thin. It verifies. It validates. It stores. It acknowledges. It doesn't perform the actual conversation workflow. That belongs somewhere else.
Slack Is Impatient
Slack expects a successful HTTP response within three seconds. If the endpoint takes too long or returns an error, Slack considers the delivery unsuccessful and retries it. Slack explicitly recommends acknowledging immediately and processing events asynchronously.
Three seconds sounds generous until your endpoint starts doing all of this:
Look up the conversation
Insert the message
Resolve the support user
Download an attachment
Publish an SSE event
Send an email
Update analytics
Call an AI model because apparently we hate latencyDon't process the whole event inside the webhook request. Accept it. Store it. Return success then process it separately. This is the inbound version of the queueing lesson from Part 5. Your customer shouldn't wait for Slack. Slack shouldn't wait for your business logic. Nobody enjoys waiting.
Creating an Event Inbox
We need somewhere durable to put events between receiving and processing them. A simple table is enough:
CREATE TABLE slack_event_inbox (
event_id VARCHAR(64) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
raw_body TEXT NOT NULL,
retry_number INTEGER NULL,
retry_reason VARCHAR(100) NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT NULL
);The name inbox is deliberate. This isn't our chat message table. It's an integration inbox. Slack delivered something. We accepted custody of it. A worker will process it later. If processing fails, the original payload remains available for inspection and another attempt.
That is considerably nicer than logging “something went wrong” and hoping observability means staring intensely at a dashboard.
Duplicate Events Are Normal
Slack retries failed event deliveries. The first retry may happen almost immediately. Later retries follow after increasing delays. Slack includes retry-number and retry-reason headers so you can see why the event returned.
This means your endpoint may receive the same event more than once. That isn't an edge case. That's documented behaviour.
Every Events API callback includes an event_id that is globally unique for that event. Use it. Make event_id the primary key or a unique key, then duplicate delivery becomes harmless.
First delivery
INSERT succeeds
Second delivery
INSERT does nothing
Third delivery
Still does nothingThe alternative is allowing one Slack reply to become three customer messages because your endpoint was slow once. Customers love receiving the same answer three times. It makes the system feel nervous.
Building slack_event_service.cfc
The endpoint shouldn't know SQL. It should hand the event to a service whose job is managing the inbound queue. Create slack_event_service.cfc:
component output="false" {
public slack_event_service function init() {
return this;
}
public boolean function enqueue_event(
required struct payload,
required string raw_body,
string retry_number = "",
string retry_reason = ""
) {
if ( !structKeyExists( arguments.payload, "event_id" ) ) {
throw(
type = "Slack.MissingEventId",
message = "Slack event callback did not include event_id.",
detail = serializeJSON( arguments.payload )
);
}
var event_type = "unknown";
if (
structKeyExists( arguments.payload, "event" )
&& structKeyExists( arguments.payload.event, "type" )
) {
event_type = arguments.payload.event.type;
}
queryExecute(
"
INSERT INTO slack_event_inbox (
event_id,
event_type,
payload,
raw_body,
retry_number,
retry_reason
)
VALUES (
:event_id,
:event_type,
:payload,
:raw_body,
:retry_number,
:retry_reason
)
ON CONFLICT ( event_id ) DO NOTHING
",
{
event_id = {
value = arguments.payload.event_id,
cfsqltype = "cf_sql_varchar"
},
event_type = {
value = event_type,
cfsqltype = "cf_sql_varchar"
},
payload = {
value = serializeJSON( arguments.payload ),
cfsqltype = "cf_sql_longvarchar"
},
raw_body = {
value = arguments.raw_body,
cfsqltype = "cf_sql_longvarchar"
},
retry_number = {
value = len( trim( arguments.retry_number ) )
? val( arguments.retry_number )
: javacast( "null", "" ),
cfsqltype = "cf_sql_integer",
null = !len( trim( arguments.retry_number ) )
},
retry_reason = {
value = arguments.retry_reason,
cfsqltype = "cf_sql_varchar",
null = !len( trim( arguments.retry_reason ) )
}
},
{
result = "insert_result"
}
);
return insert_result.recordCount > 0;
}
}The ON CONFLICT syntax shown here is PostgreSQL. For SQL Server, MySQL, or Oracle, use the database’s equivalent atomic insert-if-missing operation. Don't replace it with:
SELECT to see if it exists
Then INSERT if it does notTwo requests can pass the SELECT at the same time. Then both try to insert. Race conditions are bugs that wait until production has enough traffic to become interesting.
Let the database enforce uniqueness. It is much better at that job than your application is.
What Does the Return Value Mean?
enqueue_event() returns true when it stores a new event. It returns false when the event already exists. Both results are successful outcomes for the HTTP endpoint. A duplicate doesn't mean Slack did something wrong; it means Slack wasn't sure we received the earlier attempt.
We already have it. Acknowledge the retry and move on. Idempotency is often just the art of responding to duplicate work with a bored shrug like a Gen X'er being asked how their day is.
Don't Trust the Deprecated Token
Slack event payloads may still include a token field. Don't use it for request authentication. Slack identifies that token as the deprecated verification mechanism and recommends signed-secret verification instead. We verify:
- The raw body
- The request timestamp
- The request signature
- The signing secret
The token inside the JSON has no role in deciding whether the request is genuine. Old authentication mechanisms have a habit of surviving because removing them feels risky. Continuing to depend on them is usually riskier.
What We Have Built
At this point, the inbound path looks like this:
Slack
│
▼
Public Events Endpoint
│
├── Capture raw body
├── Reject stale timestamp
├── Verify signature
├── Handle challenge
├── Store event once
└── Return HTTP 200
│
▼
Background ProcessingThe repository now contains three important pieces:
slack.cfc
conversation_service.cfc
slack_event_service.cfcAnd one public endpoint:
slack/events.cfmMore importantly, each piece has a clear job. slack.cfc understands Slack’s API and security rules. conversation_service.cfc coordinates conversations and outbound Slack threads. slack_event_service.cfc accepts inbound events durably and idempotently. The endpoint connects HTTP to those services without turning into a 900-line monument to poor impulse control.
The Biggest Lessons
Receiving webhook events isn't difficult. Receiving them responsibly takes a little more work.
You have to preserve the raw body. Verify the signature. Reject stale requests. Handle Slack’s challenge. Respond quickly. Store events durably. Ignore duplicates. Save enough information to understand failures later.
None of that makes the demo look more impressive. All of it makes production less exciting. And despite what conference presentations may suggest, less exciting production systems are generally preferable.
Next Time
Slack can now deliver events to our application securely. We aren't doing anything useful with them yet. That changes in the next article.
In Part 7, we will process the queued message events, ignore bot messages and irrelevant activity, map Slack’s thread_ts back to the correct conversation, persist the support reply, and publish it to the browser using Server-Sent Events.
Because after six articles, it is finally time for someone on the other end to say hello.