Microsoft Teams Alerts Developer Guide - edmozley/freeitsm GitHub Wiki
Microsoft Teams alerts β Developer Guide
How the Teams channel alerts work, where they live, and the traps. The user page is Microsoft Teams alerts. The Teams bot (a messaging channel, 3.1.0) is a different subsystem - Teams and Mattermost β Developer Guide - and nothing here touches it.
Contributed by Carl Steynvaard (Open Resilience), October 2026. The design is his: the page is a friendly front over Workflows rather than a second engine - every ticked alert is a workflow - plus per-channel team routing for an L1 / L2 / L3 desk, and an Adaptive Card in place of the retired connector card. The code was rebuilt; what changed and why is below.
The shape
System β Integrations β Microsoft Teams (system/integrations/msteams.php)
β api/system/msteams_settings.php (administrators)
βΌ
msteamsSaveChannel() βββΆ msteams_channels (address encrypted)
β
ββ msteamsSyncWorkflows() βββΆ workflows (one per ticked alert: trigger + conditions +
β send_webhook, format 'teams_workflows')
ββ msteams_channel_workflows (channel_id, alert_key) β workflow_id
an event βββΆ WorkflowEngine (the same run, queue, retries and delivery log as any workflow)
βββΆ webhook_deliveries βββΆ cron/webhook_deliveries.php βββΆ Teams
It is an alerts-kind provider in includes/integrations/integrations.php; the Integrations card counts channels with an address.
π Files
| File | What it is |
|---|---|
includes/integrations/msteams.php |
MSTEAMS_ALERTS (the alert catalogue), msteamsSaveChannel(), msteamsSyncWorkflows(), msteamsAlertRule(), msteamsPrioritiesAtOrAbove(), msteamsDeleteChannel(), msteamsSendTest() |
api/system/msteams_settings.php |
get (never returns an address), save, delete, test. Administrators only; writes are POST + CSRF |
system/integrations/msteams.php |
The page: one card per channel |
system/integrations/help_msteams.php |
The in-app guide, included by help.php |
workflow/includes/engine.php |
The teams_workflows built-in format, enrichWithLinks() ({{link}}, {{ticket.url}}, {{app.url}}), ticket.team_assigned / ticket.created field lists |
includes/ticket_events.php |
ticketDispatchCreated() - now sends ticket.assigned_team_id |
msteams_channels, msteams_channel_workflows (tables) |
The channels, and which workflow each (channel, alert) owns |
tests/msteams-alerts.php |
39 checks |
The card format
teams_workflows in WorkflowEngine::BUILTIN_WEBHOOK_FORMATS (and seeded into webhook_message_formats by Database Verification, display order 40):
{"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null,
"content": {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4",
"msteams": {"width": "Full"},
"body": [{"type": "TextBlock", "text": "{{message}}", "wrap": true}],
"actions": [{"type": "Action.OpenUrl", "title": "Open in FreeITSM", "url": "{{link}}"}]}}]}
That envelope is what Teams' Post to a channel when a webhook request is received workflow expects. It goes through renderFormatBody() like every format: the template is decoded, merge codes substituted inside string values, re-encoded - so a message with quotes, backslashes or newlines cannot break the JSON.
The older teams format (a MessageCard, made for the Office 365 connectors Microsoft switched off in May 2026) is left exactly as it was, so a workflow already using it sends what it always sent.
{{link}}, {{ticket.url}}, {{app.url}}
enrichWithLinks(), called from enrichPayloadForTemplates() - so the run and the editor's Send test agree. Full addresses from publicBaseUrl(). RECORD_LINKS maps an object to its page (ticket β tickets/?ticket_id=); {{link}} is the first record's page, else the front page.
β οΈ {{link}} is never empty, on purpose. Teams refuses a card whose Action.OpenUrl has no URL, so a format that pointed its button at {{ticket.url}} would fail outright on every non-ticket trigger (a status page incident, an approval).
What saving a channel does
msteamsSyncWorkflows() makes the channel's workflows match its settings:
- Wanted = each ticked alert the settings can make (
msteamsAlertRule()returns null for a priority alert with no priority, a team alert with no teams). No address = nothing wanted. - An existing mapping whose workflow still exists β UPDATE in place (same id, so its history stays together).
- A mapping whose workflow somebody deleted β a new workflow, new mapping.
- A mapping no longer wanted β the workflow is deleted (
workflow_executionsandwebhook_deliverieskeep their rows:ON DELETE SET NULL). - Anything without a mapping is not the page's - never touched, however much it looks like one of its own.
The webhook address goes through webhookEncryptActions(), as every workflow save does.
The conditions
| Alert | Trigger | Conditions |
|---|---|---|
priority_new |
ticket.created |
ticket.priority_id in the band |
priority_raised |
ticket.priority_changed |
new_priority_id in the band and old_priority_id not in it |
team_new |
ticket.created |
ticket.assigned_team_id in the teams |
team_handover |
ticket.team_assigned |
team_id in the teams |
every_new, sla_warning, sla_breached, status_incident, approval_waiting |
their trigger | none |
The band - msteamsPrioritiesAtOrAbove(): the chosen priority and every active one with a higher display_order (higher = more urgent, as Watchtower and the Zabbix map read it). No priority name is assumed.
The traps
- TRAP: filter a ticket's team on what each event carries.
ticket.createdsendsticket.assigned_team_id- only since this change; before it, the payload had no team at all.ticket.team_assignedsends the team as a top-levelteam_id. A condition on the other one never matches - and says nothing: the alert is simply never sent. The contributed version filtered both onticket.assigned_team_id, so its team routing never fired. Both triggers' field lists inavailableFields()now say what they carry, so the editor offers the right one. - Never offer a field a trigger does not send.
ticket.assigned_team_idwas added toticket.created's list only, not to$fullTicket, because the other ticket events do not carry it. - The address is a credential. Anyone with it can post to the channel. Encrypted in
msteams_channels.webhook_urland in the workflow's action;getreturnshas_urlonly. - The page owns its workflows. An edit made in Workflows is replaced the next time the channel is saved. The workflow's description says so.
- Confidential tickets leave as their number only -
ticketRedactForOutbound(), the rule every webhook follows. dispatch()is not a test tool. It also creates in-app notifications and updates the search index. The tests run one workflow at a time withmanualFire(), which applies the conditions and queues the card exactly as a real event does.
What changed from the contribution, and why
| The contribution | Here | Why |
|---|---|---|
Priorities found by name (P1, P2), falling back to ids 4 and 3 |
A priority chosen per channel, "at or above" by order | Stock installs have Lowβ¦Urgent; ids differ between installs |
Team routing on ticket.assigned_team_id for both events |
The field each event really carries (and the team added to ticket.created) |
It never matched - see the trap |
Messages using {{ticket.assigned_team_name}} on hand-over |
{{team_name}} |
The hand-over event's team is team_id |
Workflows found by a [fitsm-teams:β¦] marker in their description |
msteams_channel_workflows |
An edited description lost the workflow; a hand-made one with the marker would have been taken over |
| Unticked alerts left as inactive workflows | Removed (history kept) | No pile of dead workflows |
| Formats inserted on save with the site address baked into the template | A built-in format with {{link}} |
One format for every install, every trigger |
| Six fixed channel slots | Any number of channels | |
"FITSM Cloud" text; SQLite settings; its own admin check and CSRF; register-patch.php |
Plain text; tables; admin_api_guard.php, the app's CSRF; a registry entry |
One implementation of each; nothing patched |
Testing
php tests/msteams-alerts.php
39 checks: the card (valid JSON whatever the message holds, the ticket's button, the front page when there is no ticket), the merge codes offered, the workflows a save makes (count, triggers, conditions, encrypted address), updating in place, removing, recreating, never touching one it did not make, validation, the alerts firing or not through the real engine and queue, Test against a local stand-in for Teams (accepted, and refused with Teams' words), and the API (administrators only, no address returned, delete removes the workflows).
What it cannot prove: that Microsoft's own endpoint accepts the card. The envelope is the one Teams documents for Workflows webhooks, and the one the contribution ran in production; Test on the page is the proof for a real channel.
Related: Microsoft Teams alerts Β· Workflows Β· Webhook Message Formats Β· Teams and Mattermost β Developer Guide Β· Developer Tests