Forward SMS to a Discord channel with a webhook
Published and updated
Create a webhook in the Discord channel settings, then run a relay that receives the textbee webhook and posts the text to it. The relay turns mentions off, so a text containing @everyone cannot ping the server.
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
- Open the channel settings in Discord, go to Integrations, then Webhooks, and create a new webhook.
- Give it a name such as "SMS" and copy the webhook URL.
- 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.
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 DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL
const MAX_BODY = 64 * 1024
const seen = new Set() // use your database in production
// Escape Markdown so a text cannot hide a link behind other words or change the formatting.
function escape(text) {
return text.replace(/[\\*_~`|>#[\]()]/g, '\\$&')
}
async function forward(event) {
const content = `**SMS from ${escape(event.sender)}**\n${escape(event.message)}`.slice(0, 2000)
const response = await fetch(DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(20_000),
// Empty parse list: no @everyone, role or user pings from a text message.
body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
})
if (!response.ok) throw failure('Discord', 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. |
| DISCORD_WEBHOOK_URL | Webhook URL from the channel settings. |
| 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: "SMS from +12015550123" |
| message | Message content, cut at 2,000 characters |
Limits to plan for
- Message content on an executed webhook is limited to 2,000 characters. docs.discord.com, checked on September 23, 2026
- Discord publishes no fixed webhook rate limit; clients read the X-RateLimit headers, and a webhook that returns 404 should not be used again. docs.discord.com, checked on September 23, 2026
Everyone who can read the channel can read the forwarded texts, including passcodes. Use a private channel for anything sensitive.
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 forward SMS to Discord without a bot?
Yes. A channel webhook is enough to post messages, and it needs no bot account or token. A bot only becomes necessary if you want to reply to texts from Discord.
What happens to a very long text?
Discord rejects message content over 2,000 characters, so the relay cuts the text there. A long SMS is usually a few hundred characters, so this rarely applies.
Can I send texts to different channels?
Create one webhook per channel and choose the URL in the relay, for example by sender number or by a keyword at the start of the text.
Sources
- docs.discord.com, checked on September 23, 2026
- docs.discord.com, checked on September 23, 2026