Test Microsoft Teams outgoing webhooks locally
Receive Microsoft Teams outgoing webhook requests on localhost with a Horizon tunnel, and verify the HMAC authorization header.
Receive Teams outgoing webhook requests on your laptop while you build, with a URL Teams can reach.
An outgoing webhook acts as a bot. A user @mentions it in a channel, Teams sends a POST to your callback URL, and your reply shows up in the same thread.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Microsoft Teams team where you can add apps
Create the outgoing webhook first
Teams shows the security token once, in a dialog, after you create the webhook. Your handler needs that token, so create the webhook before you finish the handler. Start the tunnel in the next step, then follow Add the outgoing webhook in Teams.
Start your app
Teams signs the request body. It sends an HMAC (a keyed hash) in the Authorization header as HMAC <base64 hash>. The algorithm is HMAC SHA256. The key is the security token, base64-decoded. The signed bytes are the raw body as UTF-8. Read the body with request.text() before you parse it.
Teams waits five seconds for a reply. The handler returns a JSON message that Teams posts in the thread.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const securityToken = process.env.TEAMS_SECURITY_TOKEN;
if (!securityToken) {
return new Response("Missing TEAMS_SECURITY_TOKEN", { status: 500 });
}
const body = await request.text();
const authorization = request.headers.get("authorization") ?? "";
const received = authorization.replace(/^HMAC /, "");
const expected = createHmac("sha256", Buffer.from(securityToken, "base64"))
.update(body, "utf8")
.digest("base64");
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const activity = JSON.parse(body);
console.log(`Teams message from ${activity.from?.name}: ${activity.text}`);
return Response.json({ type: "message", text: "Received by Horizon." });
}Store the token in an environment variable once Teams shows it.
TEAMS_SECURITY_TOKEN=paste-the-token-teams-shows-youStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so you would paste a new callback URL into Teams after each restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-teams-botHorizon prints HORIZON: Tunnel connected. Your public URL is https://my-teams-bot.hrzn.run. Keep this terminal open.
Add the outgoing webhook in Teams
- Select Teams in the left pane.
- Find your team and select the ••• menu, then Manage team.
- Select the Apps tab.
- Under Upload an app, select Create an outgoing webhook.
- Set Name. Users type it after an @mention.
- Set Callback URL to
https://my-teams-bot.hrzn.run/api/webhooks/teams. - Set Description.
- Select Create.
Teams shows the HMAC security token in a dialog. Copy it into TEAMS_SECURITY_TOKEN and restart npm run dev. The token doesn't expire, and each webhook has its own.
The callback URL must be HTTPS and accept POST requests with a JSON body. Horizon tunnels use HTTPS.
Trigger a request
Teams has no resend button for outgoing webhooks. Trigger a new request instead. In a public channel of the team, @mention the webhook by its Name and type a message. Teams only delivers messages that @mention the webhook, and only from public channels.
Check it works
@mention the webhook in a channel. The Horizon terminal prints one line:
POST 200 /api/webhooks/teamsYour app terminal prints the sender and text. Teams posts Received by Horizon. in the thread.
Troubleshooting
The signature doesn't match
- Decode the security token from base64 before you use it as the key. Don't pass the string as the key.
- Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst. - Strip the
HMACprefix from theAuthorizationheader before you compare. - Check that
TEAMS_SECURITY_TOKENbelongs to this webhook. Each webhook has its own token. Restartnpm run devafter you edit.env.local.
Teams says it couldn't reach the webhook
Teams allows five seconds for the reply. Return fast, and check that the Horizon terminal is still running.
The webhook never fires
- Check that you @mentioned the webhook by name.
- Use a public channel. Outgoing webhooks don't work in personal or private scopes.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-teams-bot and update the Callback URL in Teams. -s needs a subdomain you reserved, see Pricing.
Next steps
- Read Microsoft's guide to creating an outgoing webhook, including Adaptive Card replies.
Test Mailgun webhooks locally
Receive Mailgun event webhooks on localhost with a Horizon tunnel, and verify the HMAC signature in a Next.js route handler.
Test Plivo SMS webhooks locally
Receive Plivo incoming SMS webhooks on localhost with a Horizon tunnel, and verify the X-Plivo-Signature-V2 header with the Plivo Node SDK.