A Microsoft Teams bot fronted by Embr, backed by an Azure AI Foundry deployment via the OpenAI-compatible endpoint. Built with Node.js 20 + Express + TypeScript.
This sample is #3 in the Embr POC fleet and probes a specific platform finding: does Embr's ingress preserve request-body integrity faithfully enough for HMAC-SHA256 signature validation to succeed? Teams signs the raw bytes of every Outgoing Webhook callback; if any byte changes in flight (chunked re-encoding, Content-Length adjustment, JSON re-serialization, whitespace normalization), HMAC fails and the bot returns 401.
- Teams Outgoing Webhook receiver at
POST /api/teams - HMAC-SHA256 verification against the raw request body, base64-encoded, with constant-time comparison
- Async-ack-then-process pattern — a 4.5s soft timeout on the Foundry call with a fallback "thinking…" response so we beat Teams' ~5s budget
- Adaptive Card response shape for the success path; plain
{type: "message"}for fallbacks and errors - Landing page +
/api/diagto inspect the last received payload, raw body length, headers, and HMAC validation result — the diagnostic surface area for the platform finding
| Variable | What | Example |
|---|---|---|
TEAMS_WEBHOOK_SECRET |
The base64 HMAC token from the Teams Outgoing Webhook setup UI | <set-via-embr-variables-set> |
FOUNDRY_BASE_URL |
OpenAI-compatible base URL of your Foundry project | https://<resource>.services.ai.azure.com/api/projects/<project>/openai/v1 |
FOUNDRY_API_KEY |
API key for the Foundry project | <set-via-embr-variables-set> |
FOUNDRY_MODEL_DEPLOYMENT |
Deployed model name | gpt-4o-mini |
The Teams secret is provided in the Outgoing Webhook configuration UI and is already base64-encoded — paste it verbatim into the env var, the code decodes it to bytes before computing the HMAC.
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
HTML landing showing config + last request |
GET |
/health |
Health check (used by Embr's healthCheck) |
POST |
/api/teams |
Teams Outgoing Webhook callback (HMAC-validated) |
GET |
/api/diag |
JSON snapshot of state + sanitized last request |
npm install
export TEAMS_WEBHOOK_SECRET='<paste from Teams Outgoing Webhook UI>'
export FOUNDRY_BASE_URL='https://<resource>.services.ai.azure.com/api/projects/<project>/openai/v1'
export FOUNDRY_API_KEY='<your key>'
export FOUNDRY_MODEL_DEPLOYMENT='gpt-4o-mini'
npm run start
# In another terminal:
ngrok http 8080Copy the https://<id>.ngrok-free.app URL ngrok prints, append /api/teams,
and paste it into the Teams Outgoing Webhook Callback URL field. Save,
go to the Teams channel, and @your-bot-name hi. You should see an
Adaptive Card reply.
If you see Unauthorized: hmac mismatch, hit GET /api/diag to see the
exact raw body, headers, and computed-vs-provided HMAC values.
embr quickstart deploy embr-devs/embr-foundry-teams-bot-sample -i 120233234
embr variables set TEAMS_WEBHOOK_SECRET '<paste from Teams UI>'
embr variables set FOUNDRY_BASE_URL 'https://<resource>.services.ai.azure.com/api/projects/<project>/openai/v1'
embr variables set FOUNDRY_API_KEY '<your key>'
embr variables set FOUNDRY_MODEL_DEPLOYMENT 'gpt-4o-mini'Once Embr reports the deployment URL (e.g. https://teams-bot.example.embr.app),
go back to the Teams Outgoing Webhook configuration UI and update the
Callback URL to https://<embr-deployment-host>/api/teams. Save.
Then @your-bot-name hi in Teams.
This sample is intentionally narrow. It surfaces three things:
Teams' HMAC is computed over the exact bytes of the request body. If the
local quickstart works (HMAC ✓) but the deployed version returns
Unauthorized: hmac mismatch, Embr's ingress is mutating the body
between Teams and the app process. Likely culprits include:
- Chunked transfer-encoding being unwrapped/re-wrapped with different framing
Content-Lengthrecomputation that doesn't match the bytes delivered to the app- JSON normalization or whitespace stripping by an intermediary
- TLS termination + body buffering that injects/strips a trailing newline
To file the finding, capture GET /api/diag from the deployed instance after
a failing Teams call. Include rawBodyLength, contentLength,
transferEncoding, the provided and expected HMAC values, and
rawBodyPreview. That's enough to triage on the Embr side.
Webhook integrations need a stable, predictable public URL across deploys —
Teams (and most webhook providers) make you re-register the callback URL by
hand whenever the host changes, which is friction. Today there's no
declarative affordance in embr.yaml like expose_as: webhook that
guarantees URL stability or even just signals intent. Speculative shape:
ingress:
routes:
- path: /api/teams
stable: true # never change the host across deploys
raw_body: true # do not mutate body before the app sees itTeams Outgoing Webhooks are synchronous-only with a hard ~5s response budget and no follow-up channel — once you 200 the response, you cannot send another message back into the conversation. For genuine conversational bots (proactive notifications, long-running tool calls, multi-turn context), you need the Bot Framework / Azure Bot Service, which would surface a different set of findings (multi-tenant Bot config, async outbound messaging, conversation references, JWT validation against Microsoft's JWKS rather than HMAC). We deliberately picked the Outgoing Webhook variant so the Embr finding is narrow and isolated to one question: does the body survive ingress?
The 4.5s soft-timeout fallback in teams-handler.ts is a partial mitigation
of the Teams limitation — when Foundry is slow we send a "thinking…"
message synchronously rather than time out — but the user has to send
another message to retry. That's a Teams design, not an Embr gap.
src/
server.ts Express app + route wiring
teams-auth.ts HMAC verification + middleware
teams-handler.ts POST /api/teams handler, mention stripping, diag state
foundry.ts OpenAI SDK client pointed at Foundry's openai-compat endpoint
landing.ts GET / dark-themed HTML landing
embr.yaml platform: nodejs 20, port 8080, /health
package.json express, openai, dotenv, tsx
tsconfig.json strict TypeScript, ES2022