Skip to content

Microsoft Teams Alerts Developer Guide

Ed Mozley edited this page Oct 24, 2026 · 1 revision

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_executions and webhook_deliveries keep 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.created sends ticket.assigned_team_id - only since this change; before it, the payload had no team at all. ticket.team_assigned sends the team as a top-level team_id. A condition on the other one never matches - and says nothing: the alert is simply never sent. The contributed version filtered both on ticket.assigned_team_id, so its team routing never fired. Both triggers' field lists in availableFields() now say what they carry, so the editor offers the right one.
  • Never offer a field a trigger does not send. ticket.assigned_team_id was added to ticket.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_url and in the workflow's action; get returns has_url only.
  • 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 with manualFire(), 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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally