Horizon

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.

app/api/webhooks/teams/route.ts
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.

.env.local
TEAMS_SECURITY_TOKEN=paste-the-token-teams-shows-you

Start the app on port 3000.

npm run dev

Start 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-bot

Horizon prints HORIZON: Tunnel connected. Your public URL is https://my-teams-bot.hrzn.run. Keep this terminal open.

Add the outgoing webhook in Teams

  1. Select Teams in the left pane.
  2. Find your team and select the ••• menu, then Manage team.
  3. Select the Apps tab.
  4. Under Upload an app, select Create an outgoing webhook.
  5. Set Name. Users type it after an @mention.
  6. Set Callback URL to https://my-teams-bot.hrzn.run/api/webhooks/teams.
  7. Set Description.
  8. 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.

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:

Output
  POST    200  /api/webhooks/teams

Your 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.parse and JSON.stringify first.
  • Strip the HMAC prefix from the Authorization header before you compare.
  • Check that TEAMS_SECURITY_TOKEN belongs to this webhook. Each webhook has its own token. Restart npm run dev after 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

On this page