Skip to content

Slack Developer Guide

Ed Mozley edited this page Aug 7, 2026 · 2 revisions

Slack β€” Developer Guide

How the Slack channel is built, why it is built that way, and the traps that only appeared when somebody actually set it up. The user-facing page is Slack.

Shipped as #977, 2026-08-07.


1. The premise that had to be corrected first

The question that started this was "we've done Jira and Azure DevOps, shall we do Slack next?" β€” which sounds like adding tracker #3.

It is not. Slack has no work items and no states. The entire tracker engine β€” raise an issue, poll its status, pull comments back, map priorities β€” has nothing to attach to. Forcing it in would have meant pretending Slack is something it is not.

The right seam is the messaging channel engine, the one behind WhatsApp and web chat. Slack is provider #3 there, and the fit is unusually close:

MessagingProvider Slack
verifyWebhook() v0= HMAC-SHA256 over v0:{ts}:{body}, plus a ≀5-minute replay window
verifyChallenge() the url_verification handshake (the hook already existed for Meta)
parseInbound() event_callback β†’ message events
sendMessage() chat.postMessage with thread_ts
testConnection() auth.test
downloadMedia() authenticated fetch of url_private

Worth noting: a third of it already shipped. BUILTIN_WEBHOOK_FORMATS['slack'] in the workflow engine has always posted out to Slack. This work is about the other direction.


2. πŸ”‘ Bring your own app

There is no FreeITSM-published Slack app and no hosted OAuth service. includes/messaging/slack_manifest.php generates an app manifest the customer pastes into their own workspace.

The alternative was seriously considered and rejected. Publishing one app means Slack requires one fixed request URL, baked into the app config β€” so that URL has to be a server this project runs. Which means: every customer's Slack events flow through it, forever; an hour of downtime breaks every install; it can never be switched off without breaking people who did nothing wrong; and the project becomes a data processor for other companies' ticket contents.

For a free, self-hosted product that is the wrong trade. Bring-your-own costs the customer about five minutes and gives them an install nothing else can reach into.

The consequence to be honest about: inbound needs the customer's install to be internet-reachable over HTTPS. messaging_channels.ingress_mode has a relay value reserved for solving that; it is not built.


3. Where it lives, and the seam that creates

Slack sits at /system/integrations/slack with the trackers β€” because that is where a user looks for "integrations" β€” while storing rows in messaging_channels like the other channels.

Those two facts must not get confused, so the provider registry gained a kind:

'slack' => ['kind' => 'messaging', 'page' => 'slack.php', …]
  • integrationsTrackerProviders() is the safe list for anything asking "can we raise a work item in it?". Use it, or Slack gets offered as an escalation target and fails when someone tries.
  • system/integrations/provider.php delegates a messaging-kind provider to its own page rather than growing a second form shape.
  • system/integrations/index.php counts messaging-kind providers from messaging_channels, or Slack reads "Not set up" forever.
  • api/integrations/save_connection.php / api/integrations/test_connection.php reject a non-tracker provider outright β€” a stored Slack row there could never dispatch, because integrationsProviderFor() would throw.

πŸ”΄ Known debt: WhatsApp and web chat are still tabs in Tickets settings, so there are two homes for third-party connections, split by an engine distinction that means nothing to a user. Decide deliberately before a fourth channel arrives.


4. πŸ“ The files

Colour key: πŸ”Œ provider Β· πŸ“₯ inbound Β· πŸ“€ outbound Β· πŸ–₯️ UI Β· πŸ” diagnostics Β· πŸ—„οΈ schema

🎨 File What you do there
πŸ”Œ includes/messaging/SlackProvider.php Everything Slack-specific: signature + replay check, event parsing, posting, profile lookup, file download, scope reporting, channel membership
πŸ”Œ includes/messaging/slack_manifest.php The manifest, the scope list with a reason per scope, and slackWebhookUrlProblem()
πŸ”Œ includes/messaging/messaging.php messagingProvider() dispatch Β· normaliseChannelIdentifier() Β· messagingWebhookUrl() Β· messagingAdminMayAdministerChannel()
πŸ“₯ api/messaging/webhook.php The public endpoint. Per-channel, signature-verified, handles the one-time URL challenge
πŸ“₯ includes/messaging/ingest.php Message β†’ ticket. findOpenChannelTicket() and resolveSlackRequester()
πŸ“€ api/messaging/send_message.php The analyst reply, posted into the thread
πŸ–₯️ system/integrations/slack.php Workspaces, the app-setup modal, the health check
πŸ–₯️ system/integrations/help_slack.php The in-app guide
πŸ” api/messaging/slack_diagnose.php The nine health checks
πŸ”Œ api/messaging/slack_manifest.php Serves the manifest to the page
πŸ—„οΈ messaging_channels provider='slack', channel_type='slack', channel_ref = workspace id, credentials encrypted

Not touched: the tracker connectors, the workflow engine, the webhook engine.


5. The four design decisions

A thread is the conversation β€” not the sender

findOpenChannelTicket() matches on the thread for Slack and on the sender for phone channels. On WhatsApp a person has one running conversation with the service desk. In Slack the same person can have five unrelated threads open at once, and matching on the sender would pile all five onto one ticket.

The thread address (C08HELP:1719500000.000100) rides on the inbound row's to_recipients, which already means "where this arrived". parseInbound() returns it as to β€” a field the normalised-message contract always had and nothing used.

Identity: look it up, never depend on it

Halo's Slack integration requires a person's Slack email to equal their Halo email. That fails for guests, contractors, and anyone whose Slack account is a personal address.

resolveSlackRequester() asks Slack for the email, uses it when it matches an existing user, and otherwise falls back to a named case β€” "Sam Okafor (Slack)", or "Slack user @U…". Never blank, never silent.

⚠️ And the name heals. getOrCreateChannelUser() only sets a display name on INSERT, so anyone first seen while the lookup was failing would keep the placeholder for ever. The name is now corrected on that person's next message β€” but only when the stored value matches our own fallback string exactly, so a name an analyst edited by hand is never clobbered.

Ask for the fewest scopes that can do the job

The manifest asks for seven scopes and no more. Deliberately absent: channels:manage, users:write, anything writing files β€” and channels:read, even though it would have made the diagnostics easier. A Slack admin reads that list before approving, and an unexplained permission is a reason to refuse.

That constraint shaped the health check: membership is tested with conversations.history (needs channels:history, which we already require) rather than conversations.info (needs channels:read, which we do not).

Slack has no 24-hour window

That is a WhatsApp Business rule. ⚠️ It is enforced in two places that must agree β€” api/messaging/send_message.php refuses, and api/tickets/get_ticket_thread.php greys the composer out. Disagree and the UI blocks a reply the API would have accepted.


6. πŸ› The traps β€” every one found by doing it, not by testing

The unit suite was green for all of these.

The setup deadlock

Slack verifies the request URL the instant the app is created β€” which is also the instant the signing secret first exists. Requiring a signature on that handshake does not make setup strict, it makes it impossible:

create the app β†’ Slack POSTs the challenge β†’ 403 (no secret yet)
β†’ Slack refuses the URL β†’ the secret can never be collected β†’ β†Ί

The handshake is answered unsigned only while the channel has no stored secret, and logged when it happens. ⚠️ That exempts the handshake only β€” an event_callback always requires a valid signature, so nothing can be injected through the gap. Verified all four ways: no-secret + challenge β†’ 200; no-secret + event β†’ 403 with nothing reaching the database; secret set + unsigned challenge β†’ 403; secret cleared β†’ 200 again.

Slack's read methods ignore a JSON body

chat.postMessage accepts JSON. users.info does not β€” given a JSON body it parses no arguments at all and answers user_not_found for a user that plainly exists in users.list. Nothing errors.

Everything is form-encoded now, which every Slack method accepts. This survived a full test suite because auth.test takes no arguments, so the one call that could be tested without arguments was the one that could not expose it.

The old sender normaliser was phone-shaped

normaliseChannelIdentifier() stripped every non-digit, turning U08ABCDEF into +08:

U08ABCDEF β†’ +08     U08XYZQRS β†’ +08     U08LMNOPQ β†’ +08
W01AAAAAA β†’ +01     W01BBBBBB β†’ +01
5 distinct Slack users β†’ 2 identities

Five people sharing two requesters, reading each other's tickets. It now takes a channel type; any future non-phone channel must pass one. WhatsApp's behaviour was proven byte-identical over ten inputs before and after.

Bot messages come back as events

Ingesting one makes the ticket talk to itself for ever. Three separate guards (bot_id, bot_profile, subtype === 'bot_message') because Slack marks bot messages inconsistently across subtypes.

The webhook URL doubled the app path

messagingWebhookUrl() appended the app root to a configured public base URL that already contained it β†’ /freeitsm-app/freeitsm-app/…. That string is what gets pasted into Slack, and the 404 fails verification with an error mentioning nothing about a path. Pre-existing, and it affected WhatsApp too.

Scopes are granted at INSTALL time

Not a code bug β€” a Slack behaviour that produces a silently degraded result. An app created from a manifest holds a token missing most of its scopes until somebody clicks Reinstall to Workspace. Tickets still arrive; they just have no names on them. This is why the health check compares granted scopes against required ones.


7. The health check, and one thing it taught

api/messaging/slack_diagnose.php runs nine checks, each carrying the sentence that fixes the problem. A diagnostic that names a fault and stops has moved the problem, not solved it.

⚠️ Its reachability check nearly shipped as a liar. It makes the server call its own public address, which loops back through the same web server β€” and that failed twice in three runs against a tunnel that was demonstrably up. It now tries three times, and when it still cannot confirm it reports what it knows: if a message has arrived from Slack, that is proof the address works, so it shows a warning with the evidence rather than a failure.

A check that cries wolf gets ignored the one time it is right.


8. Verifying a change

  • tests/integrations/run.php β€” 353 assertions; the registry gained a kind so this must stay green
  • Signature checks with negative controls: tampered body, wrong secret, 10-minute-old timestamp, missing headers
  • Event parsing including every echo guard, and the watch_channel scoping
  • An end-to-end ingest against the real database that asserts its own cleanup
  • ⚠️ A live run. Every bug in section 6 was found this way, with the suite green. Post a message, reply from the inbox, reply again in Slack, and read the thread back with conversations.replies to prove the reply threaded rather than landing in the channel.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally