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
webhookchannel works for Teams Workflows if you: wrap the card in themessage/attachmentsenvelope, accept status202, and addContent-Type: application/jsonvia a reverse proxy. - A small custom
teamschannel 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)
- 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 */ } }
] }
- 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. - HTTP 400
InvalidRequestContent. This one is the real trap. Thewebhookchannel sends its body without aContent-Typeheader and has no option to set one (Go’shttp.NewRequestdoesn’t add one). The Teams endpoint only acceptsapplication/jsonand 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:
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.Marshalalso 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
internalreferences (the import andinternal.Version.Version) with a literal version string and depend only onicinga-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 customteamstype 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 — thecontact_address.addresscolumn istext.
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.phplikewebhookis 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)
-
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