Guides
Setting up Microsoft Teams
Approval requests arrive in a Teams channel as an Adaptive Card that says who wants what and why it was held, with Approve, Deny and Open in console. Approve opens a short confirmation (an Action.ShowCard) whose Confirm approval button is the one that submits; Deny submits at once. At or above the chat line, Approve is a link to the console instead. Escalations, freezes, incidents and kill-switch drill results arrive as cards in the same channel.
A decision from Teams is the console’s decision: the same rules as the console and as Slack (see setup-slack.md): a mapped member who can decide for that agent, named approvers, separation of duties, holds, a fresh check against the mandate, and the chat line above which approval needs the console. It is recorded with channel teams and the person’s Microsoft Entra object id (or email) on the approval and in the evidence ledger.
There are two ways to connect Teams. Use the Teams app when the server has one: nothing to paste, no relay, cards change in place, and approvers can be messaged one to one. The Workflows webhook below is the fallback for a server without the app.
#The Teams app
#What the operator registers, once
One bot, single tenant, in the operator’s own Microsoft Entra tenant. New multi-tenant bots were deprecated after 31 July 2025 (https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-authentication); a single-tenant bot reaches customers’ tenants by being installed there from its app package.
- In the Azure portal, Create a resource, Azure Bot. Type of App: Single Tenant. Creation type: Create new Microsoft App ID. Any resource group and the free pricing tier will do.
- On the bot’s Configuration page: Messaging endpoint
https://<host>/teams/messages. Note the Microsoft App ID and the App Tenant ID. - Manage Password (it opens the Entra app registration): Certificates and secrets, New client secret. Copy the value. Or upload a certificate instead and keep its private key and the SHA-1 thumbprint Entra shows.
- On the bot’s Channels page, add Microsoft Teams and accept the terms.
- Set the secrets:
fly secrets set MICROSOFT_BOT_APP_ID=<app id> MICROSOFT_BOT_TENANT_ID=<tenant id> MICROSOFT_BOT_APP_PASSWORD=<client secret>
# or, with a certificate instead of a password:
fly secrets set MICROSOFT_BOT_CERTIFICATE_KEY="$(cat bot-key.pem)" MICROSOFT_BOT_CERTIFICATE_THUMBPRINT=<sha-1 thumbprint>To list the app in the Teams store instead of customers uploading it, submit the same package through Partner Center; that is optional and not needed to start.
#What the customer does
- On Connections, choose Connect on Microsoft Teams and Download the Teams app (a zip with
manifest.jsonand two icons drawn from the mark). - A Teams admin uploads it once: Teams admin centre, Teams apps, Manage apps, Upload new app. Where the organisation lets people upload custom apps, anyone can use Apps, Manage your apps, Upload an app in Teams.
- Add the app to the team. The bot posts Connect to Immiscible in the channel; an owner or admin of the Immiscible workspace opens it, sees the team and channel, and confirms. The link works once, for seven days.
People are linked to their Immiscible accounts the first time they press a button: the bot asks Teams for their email from the team roster and links them if it is a member’s. A decision is recorded with their Entra object id. Removing the app from the team disconnects the workspace.
#How it works
From Microsoft’s documentation, built without the Bot Framework SDK:
- Every inbound activity carries a JWT, verified against the key set named by
https://login.botframework.com/v1/.well-known/openidconfiguration(read at least daily, and again on an unknown key id): RS256, issuerhttps://api.botframework.com, audience the bot’s app id, five minutes of clock skew, theserviceUrlclaim equal to the activity’s, and the key endorsed formsteams, else 403 (https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication). The service URL must also be a Microsoft Bot Connector host before a token is sent to it. - Outbound, a client credentials token from
https://login.microsoftonline.com/<MICROSOFT_BOT_TENANT_ID>/oauth2/v2.0/token, scopehttps://api.botframework.com/.default, with the password or a certificate client assertion. - Approvals go to the channel and, one to one, to each decider already linked in Teams (https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages). The cards are the same as the webhook’s.
- Buttons:
Action.Submitarrives as a message whose value is the button’s data;Action.Executeas anadaptiveCard/actioninvoke, answered with the new card. Each button carries a token for its decision, so a press cannot be turned into another decision. Every copy of the card is updated in place after a decision, wherever it was made. - The rules are the console’s: a linked member who can decide for that agent, named approvers, separation of duties, holds, and the chat line above which approving needs the console (Teams answers with the console link).
#The Workflows webhook
The Workflows (Power Automate) webhook style, with a signed callback:
- Immiscible posts each card to a Workflows webhook URL you create.
- Your flow posts the card to the channel and, for approvals, waits for someone to press Approve or Deny.
- Your flow, or a small relay it calls, sends the submission and the responder to Immiscible’s callback, signed with an HMAC-SHA256 secret that only your relay and Immiscible hold.
Be clear about what that means:
- The relay is part of your trust boundary. Immiscible trusts the responder the relay reports, because Microsoft signed that person in and the relay proves itself with the secret. Anyone holding the secret can report any responder. Keep it in a key vault, not in the flow’s plain text.
- Power Automate cannot compute an HMAC by itself. There is no HMAC expression. Compute the signature in an Azure Function or a Logic Apps Standard inline code step (below), or call one from the flow.
- The HTTP action in Power Automate is a premium connector. Calling the relay from a cloud flow needs a licence that includes it.
- Cards are not updated in place by Immiscible. A Workflows webhook gives no message id back. The callback’s answer includes an updated card saying who decided; use the flow’s “Update an adaptive card in a chat or channel” step to show it.
- Each card button carries a token (an HMAC of the workspace, the approval and the decision), so a relay cannot turn a Deny into an Approve, or answer an approval it was not shown, without the secret.
#1. Create the flow
In Teams, open Workflows, and start from Post to a channel when a webhook request is received, or build it in Power Automate:
- Trigger: When a Teams webhook request is received. Copy the HTTP POST URL it shows.
- Immiscible’s body is
{ "type": "message", "immiscible": { "kind": "approval" | "notice" }, "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "content": <card> } ] }. Add a condition onimmiscible.kind. - For
notice: Post card in a chat or channel with the card. - For
approval: Post adaptive card in a chat or channel and wait for a response, with the card. Its outputs include the submitteddata(theimmiscibleobject from the button, whether Deny or Confirm approval inside the Approve card:workspaceId,approvalId,decision,token) and the responder (their email and Entra object id). - Then an HTTP step that POSTs to your relay (or straight to Immiscible if your relay is inline code) with:
{
"approvalId": "<data.immiscible.approvalId>",
"decision": "<data.immiscible.decision>",
"token": "<data.immiscible.token>",
"workspaceId": "<data.immiscible.workspaceId>",
"responder": { "aadObjectId": "<responder object id>", "email": "<responder email>" }
}- Optionally, Update an adaptive card in a chat or channel with the
cardfrom the answer.
#2. Connect it in Immiscible
An owner or admin, in Settings, Teams, pastes the webhook URL, or:
PUT /api/w/:wid/chat/teams { "webhookUrl": "https://....logic.azure.com/workflows/..." }The URL must be https and must not be a private, loopback or link-local address; it is sealed at rest. The answer includes secret (it starts tmsec_), shown once. Put it in your relay’s key vault. To replace it: { "rotateSecret": true }. Then Send a test message.
#3. The callback
POST /teams/callback/:wid
content-type: application/json
x-immiscible-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>The timestamp must be within five minutes of Immiscible’s clock, the signature is compared in constant time, and each signature is accepted once: a replay is refused. Answers:
| Status | Body |
|---|---|
| 200 | { ok: true, status: "approved" | "denied", decidedBy, card } |
| 401 | bad or missing signature, a replay, or a token not from a card Immiscible sent |
| 403 | not_linked (responder not mapped), forbidden, not_an_approver, separation_of_duties, or step_up_required with url to the console |
| 409 | already decided, the agent is frozen, or the mandate no longer allows it |
A relay as an Azure Function (Node 18 or later), holding the secret in IMMISCIBLE_TEAMS_SECRET:
import { createHmac } from 'node:crypto';
export default async function (context, req) {
const body = JSON.stringify(req.body);
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac('sha256', process.env.IMMISCIBLE_TEAMS_SECRET).update(`${t}.${body}`).digest('hex');
const res = await fetch(`https://YOUR-IMMISCIBLE-HOST/teams/callback/${process.env.IMMISCIBLE_WORKSPACE_ID}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-immiscible-signature': `t=${t},v1=${v1}` },
body,
});
context.res = { status: res.status, headers: { 'content-type': 'application/json' }, body: await res.text() };
}Protect the function itself (a function key, or Entra authentication) so only your flow can call it.
#4. Map members
The responder is matched to a member of the workspace by Entra object id when one is mapped, otherwise by email.
POST /api/w/:wid/chat/teams/members/sync map every member by their Immiscible email
POST /api/w/:wid/chat/teams/members { email, aadObjectId } map one member by object id
GET /api/w/:wid/chat/teams/members
DELETE /api/w/:wid/chat/teams/members/:externalIdMapping by object id is stronger: an email can be reassigned, an object id is not.
#Disconnecting
Settings, Teams, Disconnect (DELETE /api/w/:wid/chat/teams). The URL, the secret and the member mappings are deleted, and the callback answers 404 for the workspace. Turn the flow off in Power Automate too.