Forward SMS to Microsoft Teams with a Workflows webhook
Published and updated
Add the "Send webhook alerts to a channel" workflow to the Teams channel, then run a relay that receives the textbee webhook and posts each text to the workflow URL as an Adaptive Card. Office 365 connector webhooks no longer work, so a Workflows URL is the way in.
What you need
- The textbee app on the Android phone that holds the SIM, with receiving turned on. Download the app.
- A place to run the relay with a public HTTPS address: a small server, a container or a serverless function. textbee rejects private and loopback addresses.
Before you start
- In Teams, open the channel, choose More options (...) next to its name, then Workflows.
- Pick the template "Send webhook alerts to a channel", name the workflow, confirm the team and channel, and save.
- Copy the webhook link the workflow shows. It is the URL the relay posts to.
The relay
It checks the HMAC-SHA256 signature in the X-Signature header, skips anything but received messages, and ignores a delivery it has already forwarded. When the destination is down it answers 502, so textbee delivers the text again later; an error a retry cannot fix, such as a deleted webhook, is logged instead. It needs Node.js 18 or newer and only the standard library, so there is nothing to install.
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
const SECRET = process.env.TEXTBEE_WEBHOOK_SECRET
if (!SECRET) throw new Error('Set TEXTBEE_WEBHOOK_SECRET')
const TEAMS_WEBHOOK_URL = process.env.TEAMS_WEBHOOK_URL
const MAX_BODY = 64 * 1024
const seen = new Set() // use your database in production
async function forward(event) {
const card = {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.4',
body: [
{ type: 'TextBlock', text: `SMS from ${event.sender}`, weight: 'Bolder' },
// A TextRun shows the text as sent, so Markdown in a text cannot turn into a link.
{ type: 'RichTextBlock', inlines: [{ type: 'TextRun', text: event.message }] },
],
}
const response = await fetch(TEAMS_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(20_000),
body: JSON.stringify({
type: 'message',
attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content: card }],
}),
})
if (!response.ok) throw failure('Teams', response.status)
}
// A 4xx other than 408 or 429 will not fix itself, so it is logged instead of retried.
function failure(name, status) {
const error = new Error(`${name} answered ${status}`)
error.permanent = status >= 400 && status < 500 && status !== 408 && status !== 429
return error
}
function verified(rawBody, signature = '') {
const expected = Buffer.from(createHmac('sha256', SECRET).update(rawBody).digest('hex'))
const received = Buffer.from(signature)
return received.length === expected.length && timingSafeEqual(received, expected)
}
createServer((request, response) => {
const chunks = []
let size = 0
request.on('data', (chunk) => {
size += chunk.length
if (size <= MAX_BODY) chunks.push(chunk)
})
request.on('end', async () => {
if (size > MAX_BODY) return response.writeHead(413).end()
const rawBody = Buffer.concat(chunks)
if (!verified(rawBody, request.headers['x-signature'])) return response.writeHead(401).end()
const event = JSON.parse(rawBody.toString())
if (event.webhookEvent !== 'MESSAGE_RECEIVED' || seen.has(event.idempotencyKey)) {
return response.writeHead(200).end()
}
try {
await forward(event)
seen.add(event.idempotencyKey)
response.writeHead(200).end()
} catch (error) {
console.error(error.message)
// 502 makes textbee retry the delivery; a permanent failure is not worth retrying.
response.writeHead(error.permanent ? 200 : 502).end()
}
})
}).listen(process.env.PORT ?? 3000)| Environment variable | What it holds |
|---|---|
| TEXTBEE_WEBHOOK_SECRET | The signing secret you set on the webhook in the textbee dashboard, at least 20 characters. |
| TEAMS_WEBHOOK_URL | Webhook link from the Teams workflow. |
| PORT | Port the relay listens on. Defaults to 3000. |
Register the webhook in textbee
- Start the relay and note its public HTTPS address.
- In the textbee dashboard open Webhooks, choose Create Webhook, and enter that address as the Delivery URL.
- Generate a Signing Secret, select the MESSAGE_RECEIVED event, and set the same secret as TEXTBEE_WEBHOOK_SECRET on the relay.
- Text the phone from a different phone and check that the forward arrives.
What arrives
| Webhook field | Where it goes |
|---|---|
| sender | Bold first line of the card: "SMS from +12015550123" |
| message | Card text, wrapped |
Limits to plan for
- Office 365 Connectors in Teams, including their incoming webhooks, stopped working after a rollout on 18 to 22 May 2026; Microsoft recommends Power Automate workflows instead. devblogs.microsoft.com, checked on September 23, 2026
- A Workflows webhook message is limited to 28 KB, and more than four requests a second are throttled. learn.microsoft.com, checked on September 23, 2026
- The webhook takes a message with an Adaptive Card attachment of content type application/vnd.microsoft.card.adaptive. learn.microsoft.com, checked on September 23, 2026
The workflow link is a secret: anyone who has it can post to the channel. A link from an old Office 365 incoming webhook will not work; create the workflow again and use its link.
textbee delivers the webhook once the phone has uploaded the text, so a phone that was offline delivers its backlog when it reconnects. Texts received more than 48 hours before the upload are stored but not sent to webhooks. A 5xx answer or a timeout is retried, up to 10 attempts in all with growing gaps, and a subscription that keeps failing is paused. Received messages count toward the plan allowance the same as sent ones.
Frequently asked questions
Why did my Teams incoming webhook stop working?
Microsoft retired Office 365 Connectors in Teams, and connector webhooks stopped working after the May 2026 rollout. Create a workflow from the "Send webhook alerts to a channel" template and use its link instead.
Why send an Adaptive Card instead of plain text?
The Workflows webhook is documented for Adaptive Card messages, and a card keeps the sender line bold and the text wrapped. The relay builds the smallest card that shows both.
Can I reply to a text from Teams?
Not through this workflow, which only posts. A reply path needs a bot or a flow that calls POST /gateway/send-sms.
Sources
- devblogs.microsoft.com, checked on September 23, 2026
- learn.microsoft.com, checked on September 23, 2026
- learn.microsoft.com, checked on September 23, 2026