Icingaweb Notification for MS Teams over Powerautomate Workflows

Hello Community,

DISCLAIMER:
I am not a developer, but an Architect and Solution Consultant. I used Claud Opus 4.8 to debug the webhook plugin and find a proper solution.

I was trying to implement MS Teams notifications into the Icingaweb2 Notifications Module via the webhook plugin and how a few of you allready may know, I failed. Here the reason:


TL;DR

  • The webhook channel works for Teams Workflows if you: wrap the card in the message/attachments envelope, accept status 202, and add Content-Type: application/json via a reverse proxy.
  • A small custom teams channel plugin removes the proxy entirely by setting the header itself and building the envelope in Go.
  • The contact-form “Microsoft Teams” field is required-because-default-channel and capped at 255 chars by a web-form validator only; the URL goes in the channel, not the contact — use a placeholder for the address.

The Teams-specific gotchas (with the webhook channel)

  1. Envelope. Modern Teams Workflows webhooks do not accept a bare Adaptive Card. The card must be wrapped:

json

   { "type": "message",
     "attachments": [
       { "contentType": "application/vnd.microsoft.card.adaptive",
         "contentUrl": null,
         "content": { /* AdaptiveCard */ } }
     ] }
  1. HTTP 202. Power Automate answers a successful post with 202 Accepted, not 200. Set the channel’s Response Status Codes to 200,202, otherwise the daemon logs the successful delivery as an error.
  2. HTTP 400 InvalidRequestContent. This one is the real trap. The webhook channel sends its body without a Content-Type header and has no option to set one (Go’s http.NewRequest doesn’t add one). The Teams endpoint only accepts application/json and otherwise rejects with:
   {"error":{"code":"InvalidRequestContent","message":"The input body ... must be of type JSON, but was of type 'application/octet-stream'."}}

You can confirm it in seconds: post the rendered JSON with curl with -H "Content-Type: application/json" (→ 202) and without it (→ 400).

DISCLAIMER:
I created a test Workflow URL that is already deletet. I post it here as an example how to configure the reverse proxy.

Workflow URL:

https://default7d1c77852d8a437db8421ed5d8fbe0.0a.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/27/workflows/b03fe0c6819247ddb4de498576eaee9f/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=0M-pnN-JHJtkPKxTbLzCSH7ockuoJHoUJZyDW_hy7R8

This leads to an apache config:

    ProxyPass        "/" "https://default7d1c77852d8a437db8421ed5d8fbe0.0a.environment.api.powerplatform.com/" nocanon

    ProxyPassReverse "/" "https://default7d1c77852d8a437db8421ed5d8fbe0.0a.environment.api.powerplatform.com/"

With the stock webhook channel the only workaround is a tiny reverse proxy that injects the header, e.g. nginx:

nginx

   server {
       listen 127.0.0.1:8099;
       location / {
           proxy_pass https://<your-host>.logic.azure.com;   # scheme+host only!
           proxy_ssl_server_name on;
           proxy_set_header Host <your-host>.logic.azure.com;
           proxy_set_header Content-Type application/json;
       }
   }

…or Apache (mod_proxy_http, mod_headers):

apache

   Listen 127.0.0.1:8099
   <VirtualHost 127.0.0.1:8099>
       ProxyRequests Off
       ProxyPreserveHost Off
       SSLProxyEngine on
       RequestHeader set Content-Type "application/json"
       ProxyPass        "/" "https://<your-host>/" nocanon
       ProxyPassReverse "/" "https://<your-host>/"
   </VirtualHost>

The channel’s URL Template then points at http://127.0.0.1:8099/… keeping the full original path + query (the sig=… token). With proxy_pass set to scheme+host only, the proxy forwards path and query unchanged.

That works, but a proxy just to add one header is unsatisfying — which is why a dedicated plugin is the better answer.


The clean solution: a dedicated teams channel plugin

Icinga Notifications discovers channel plugins as standalone binaries in channels_dir (default <libexecdir>/icinga-notifications/channels). On start, the daemon runs each binary’s GetInfo and upserts it into available_channel_type. The binary’s filename becomes the channel type.

The plugin below is modelled on the official webhook channel, with the Teams specifics baked in:

  • always sends Content-Type: application/json;
  • wraps the Adaptive Card in the Teams envelope in Go, so the user template can only ever produce the card itself and never corrupt the envelope (json.Marshal also validates the rendered card and fails early with a clear message instead of a puzzling 400);
  • defaults Response Status Codes to 200,202;
  • surfaces the Teams error body in the daemon log on rejection;
  • ships a colour-coded default Adaptive Card (green/amber/red by severity), so it works out of the box with just a webhook URL.

Drop this at cmd/channels/teams/main.go in an Icinga Notifications source checkout (it imports the repo’s internal package for the version string):

Build & install

bash

# from an icinga-notifications source checkout matching your installed version
# (requires Go >= 1.26)
go build -o build/channels/ ./cmd/channels/...

install -m0755 build/channels/teams /usr/libexec/icinga-notifications/channels/
systemctl restart icinga-notifications

On restart the daemon registers a new channel type Microsoft Teams. In Icinga Notifications Web, create a channel of that type, paste the Power Automate webhook URL, and you’re done — the card template and status codes have working defaults.

If you’d rather build it outside the repo, replace the two internal references (the import and internal.Version.Version) with a literal version string and depend only on icinga-go-library.


One last gotcha: the contact “address” field

After adding the channel you’ll notice the contact form shows a required Microsoft Teams field, and pasting the webhook URL there fails with String should be 255 characters long, 296 given. Two things are going on, both in icinga-notifications-web, both unrelated to delivery:

  • The contact form renders one address field per channel type. It becomes required only when that type is the contact’s Default Channel — with a hardcoded exception for the literal type webhook (ContactForm.php: $type === $defaultType && $type !== 'webhook'). A custom teams type isn’t in that exception, so as the default channel it’s required.
  • The 255 limit is a form-only StringLengthValidator(['max' => 255]). It is not a DB constraint — the contact_address.address column is text.

Crucially, the webhook URL does not belong in the contact address. The plugin ignores .Contact.Addresses entirely and posts to the channel’s configured URL, and the daemon does not skip contacts that lack a matching address. So:

  • Quick fix: put any short non-empty placeholder (e.g. the team name) in the contact’s Microsoft Teams field. Delivery still uses the channel URL.
  • Nicer UX: exempt your type in ContactForm.php like webhook is exempt: $type === $defaultType && ! in_array($type, ['webhook', 'teams'], true) (re-apply after web-module upgrades).
  • Proper fix (upstream): drive that exemption from a channel-type property (“does this channel need a per-contact address?”) instead of a hardcoded name.

Give as much information as you can, e.g.

  • Icinga Web 2 version: 2.14.0

  • Used modules and their versions (System - About)

    • Loaded Modules

      director 1.11.9
      fileshipper 1.2.0
      icingadb 1.4.0
      incubator 0.23.0
      notifications 0.3.0
      setup 2.14.0
  • Web browser used

    • Edge and Firefox (latest)
  • Icinga 2 version used (icinga2 --version): r2.16.4-1

  • PHP version used (php --version): 8.3.6

  • Server operating system and version: ubuntu 24.04.4