Integrations

Microsoft Teams webhooks, explained

Network cables and hardware on a workbench, no screens of software

I am Marcus Webb, guest contributor. I implement Teams side integrations for internal tools teams, and I get called when an incoming webhook starts posting stack traces into a leadership channel. This piece is implementation-focused: what incoming and outgoing webhooks are, why the incoming URL is a secret, how I think about payload shape, a small illustrative JSON example, how outgoing webhooks behave as commands, and when I refuse to use incoming webhooks because there is no user auth.

I do not work for Microsoft and I am not writing partner marketing. If a screenshot of the official UI would help, look it up yourself. I am staying with concepts and operational practice.

Incoming webhook in one sentence

An incoming webhook gives you an HTTPS URL. Your system POSTs a message body to that URL. Teams posts something into a channel on the other end. That is the whole product promise. No interactive login. No per-user identity. No fancy conversation state unless you build it elsewhere.

I use incoming webhooks for firehose style signals: build failed, certificate expires in fourteen days, overnight job finished, queue depth crossed a threshold. The channel is a billboard. Billboards do not know who you are.

Outgoing webhook as a command surface

Outgoing webhooks flip the direction. Someone in a channel types a trigger word. Teams sends a payload to your HTTPS endpoint. Your service responds with a message Teams can show. It feels like a lightweight command.

Example shape from a tools channel I support: a user types a trigger that asks for package ownership. My service looks up the package name in an internal index and replies with owner and on-call alias. The reply is text. No admin powers. No secret dump.

Outgoing webhooks are still not full bots. Hard timeouts, limited interaction models, and operational constraints apply. When you need dialogs, auth against Entra ID, or proactive messaging with stored conversation references, you graduate to a proper bot. I say that early so nobody paints themselves into a webhook corner.

The incoming URL is a secret

Treat the incoming webhook URL like a password that publishes to your channel. Anyone who has it can post. There is no user consent prompt at post time.

Do not commit it to git. Do not paste it into a shared screenshot. Do not drop it in a wiki page titled "integrations." Store it in a secret store. Inject it at runtime. Restrict who can read the secret.

If it leaks, rotate it. Rotation means create a new webhook, update producers, delete the old webhook, and confirm the old URL returns failure. Then scan logs and chat history for the old URL string. People paste URLs into tickets. Clean those up.

I keep a simple incident note template: when discovered, which channels exposed, which producers updated, time old URL died, who approved rotation. Boring paperwork prevents repeat leaks.

Payload shape in my own words

You send JSON over HTTPS. At minimum you provide text content Teams can render. Many setups also allow richer card-like structures, but I start with plain text until the channel proves it needs more. Plain text fails loudly and reads cleanly on mobile.

Think in three layers. Envelope: you are POSTing to the webhook URL. Body: JSON object. Message fields: the human readable parts. Keep titles short. Put details in the text. Include a link back to the system that generated the event so people can leave Teams when they need depth.

Here is a short illustrative JSON message I use in training. It is text oriented, not an exploit, not a scraper, just a shape:

{"text":"Build catalog-api failed on main. Log: https://builds.example.internal/jobs/1842","summary":"catalog-api build failed"}

Producers should set a useful User-Agent and timeout. Retries need idempotency. If your monitor retries three times, the channel should not get three identical failures unless you want that pain. Hash the event ID and suppress duplicates for a short window, same idea I use in CRM notifications.

Auth reality check: when I will not use incoming webhooks

Incoming webhooks do not authenticate the end user. They authenticate possession of the URL. That is the wrong tool when the message must prove who did something, when the content is per-user sensitive, or when you need to write back into a system as that user.

I refuse incoming webhooks for:

  • Anything that looks like an approval with legal weight
  • Posts that include personal data beyond a public work signal
  • Flows that must act as the user in Exchange, CRM, or source control
  • Public channels where a leaked URL would spam customers or partners

For those cases I want a bot or an app with delegated or application permissions, audited tokens, and least privilege. Webhooks are a speaker wire. They are not an identity platform.

Outgoing webhooks give you the initiating user in the payload Teams sends your service, which is better for command style tools, but you still must authorize in your backend. Do not trust a display name. Resolve against your directory and enforce group membership.

Hardening checklist I run before go-live

  1. Secret stored outside source control
  2. Producer hosts restricted by network policy where possible
  3. Message template reviewed for accidental secret echo
  4. Rate limits on the producer so a retry storm cannot flood the channel
  5. Runbook for rotation posted where operators can find it
  6. Dead-letter handling if Teams returns errors
  7. Pilot channel first, production channel second

I also add a canary post on deploy that says which version of the producer is live. When someone asks "did the new alert ship," the channel already answered.

Choosing between webhook, connector, and bot

If you only need system-to-channel announcements and can protect a URL, incoming webhook is fine. If humans must invoke a short command and get a reply, outgoing webhook can work for narrow cases. If you need auth, graph calls, or long-running conversations, build a bot or use a first-party app pattern.

People under-estimate the support cost of "just a webhook." Six months later nobody knows which service owns the URL, the channel has become a junk drawer, and security is scanning for leaked secrets in git history. Name an owner. Put the owner in the channel description. Schedule a quarterly review that asks whether the webhook still deserves to live.

One more implementation note. Format your text for scanning. Leading with the system name and severity helps on-call. "CRITICAL catalog-api build failed" beats a paragraph that buries the service name. Webhooks amplify writing quality. Bad sentences become bad pages of chat.

That is the working model I carry into design reviews. Incoming for broadcast with a guarded URL. Outgoing for small commands with server-side authorization. Rotate on leak. Keep JSON boring. Skip webhooks when you need real user auth. Ship the canary. Sleep better.

Implementation details that bite in week two. TLS matters: your producer must trust standard public CAs, and your outgoing webhook endpoint must present a valid certificate Teams can validate. Self-signed experiments die in production. Timeouts matter: if your outgoing handler calls three internal APIs sequentially, you will exceed the reply window. Cache lookups. Precompute. Return a short ack and finish heavy work asynchronously if your architecture allows, with a follow-up message through a safer channel when needed.

Logging should never include the full incoming webhook URL. Log a fingerprint of the secret, like the last four characters of a path segment, so operators can tell which webhook fired without reprinting the secret into Splunk. I have seen entire URLs land in centralized logs and then in a ticket exported to a vendor. That is a rotation event.

Card complexity is another slow leak of reliability. Start with text. If the channel truly needs buttons, that is a signal you may already be past webhook territory and into a bot with authenticated actions. Buttons that hit anonymous HTTP endpoints recreate the same secret-URL problem in a prettier wrapper.

For outgoing commands, define a strict allowlist of triggers and argument shapes. If you accept free text after the trigger, sanitize before you interpolate into queries. Yes, even internal tools. I have watched a playful channel turn a lookup command into unexpected query load because nobody capped string length. Cap length. Cap rate per user. Return a clear error string when limits hit.

Environment separation helps. Dev webhook posts to a dev channel. Prod webhook posts to a prod channel. Never share URLs across environments "just for a quick test." Quick tests become permanent. Name secrets with environment prefixes in your vault so the wrong injection is obvious in review.

When you retire a webhook, delete it, then keep the producer config pointing nowhere until you remove the producer code path. Orphaned producers that fail quietly are fine. Orphaned producers that fallback to an old hard-coded URL are not. Grep your repos for the host pattern your tenant uses for webhook endpoints during retirement.

I close implementation reviews with one question: if this URL appeared on a public paste site tonight, how fast could we rotate and how bad would the blast radius be? If the answer is slow or wide, shrink the channel audience, strip the payload, or replace the webhook with something that authenticates. That question alone has killed more bad designs than any style guide.