textbee Logotextbee.dev
Plans from $9.99/mo.View Plans

Forward SMS to a Slack channel with an incoming webhook

Published and updated

Create a Slack app with an incoming webhook for the channel, then run a relay that receives the textbee webhook and posts the text to that webhook URL. The relay escapes the message first, so a text containing a mention cannot notify the whole channel.

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

  1. Create a Slack app for your workspace and turn on Incoming Webhooks in its settings.
  2. Add a new webhook to the workspace, pick the channel that receives the texts, and copy the webhook URL.
  3. Keep the URL secret. Anyone with it can post to that channel.

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.

relay.mjs
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 SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL
const MAX_BODY = 64 * 1024
const seen = new Set() // use your database in production

// Slack reads <, > and & as control characters, so escape them before posting.
function escape(text) {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

async function forward(event) {
  const response = await fetch(SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    signal: AbortSignal.timeout(20_000),
    body: JSON.stringify({ text: `*SMS from ${escape(event.sender)}*\n${escape(event.message)}` }),
  })
  if (!response.ok) throw failure('Slack', 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 variableWhat it holds
TEXTBEE_WEBHOOK_SECRETThe signing secret you set on the webhook in the textbee dashboard, at least 20 characters.
SLACK_WEBHOOK_URLIncoming webhook URL for the channel.
PORTPort the relay listens on. Defaults to 3000.

Register the webhook in textbee

  1. Start the relay and note its public HTTPS address.
  2. In the textbee dashboard open Webhooks, choose Create Webhook, and enter that address as the Delivery URL.
  3. Generate a Signing Secret, select the MESSAGE_RECEIVED event, and set the same secret as TEXTBEE_WEBHOOK_SECRET on the relay.
  4. Text the phone from a different phone and check that the forward arrives.

What arrives

Webhook fieldWhere it goes
senderBold first line: "SMS from +12015550123"
messageMessage text, escaped

Limits to plan for

  • Incoming webhooks are limited to 1 message a second, with short bursts allowed; over the limit Slack answers 429 with a Retry-After header. docs.slack.dev, checked on September 23, 2026
  • The webhook URL contains a secret, and Slack revokes webhook URLs it finds leaked. docs.slack.dev, checked on September 23, 2026

An incoming webhook posts to one channel. To route texts to different channels by sender, create one webhook per channel and pick the URL in the relay.

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

Can I reply to a text from Slack?

Not with an incoming webhook, which only posts. Replies need a Slack app that listens for messages and calls POST /gateway/send-sms, which is a bigger build than this relay.

Why escape the message?

Slack treats text in angle brackets as links and mentions. Without escaping, a text that contains <!channel> would notify everyone in the channel.

What if the webhook URL leaks?

Slack revokes webhook URLs it finds leaked, and the relay then gets an error for every text. Add a new webhook to the app, put the new URL in SLACK_WEBHOOK_URL, and restart the relay. textbee retries the texts that failed in between.

Sources

  1. docs.slack.dev, checked on September 23, 2026
  2. docs.slack.dev, checked on September 23, 2026

Read next