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

Forward SMS to a REST API with your own auth and field names

Published and updated

If your API can verify an HMAC signature, point the textbee webhook straight at it and skip the relay. If the API expects its own bearer token or field names, run this relay: it verifies the textbee signature, reshapes the event, and posts it with your credentials and an idempotency key.

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. Decide the endpoint and the body your API expects. The relay below posts from, text, receivedAt and id.
  2. Create a token for the relay in your API, with permission to create inbound messages only.
  3. Make the endpoint treat a repeated Idempotency-Key as a duplicate, since textbee retries failed deliveries.

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

async function forward(event) {
  const response = await fetch(TARGET_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${TARGET_TOKEN}`,
      'Idempotency-Key': event.idempotencyKey,
    },
    signal: AbortSignal.timeout(20_000),
    body: JSON.stringify({
      id: event.smsId,
      from: event.sender,
      text: event.message,
      receivedAt: event.receivedAt,
    }),
  })
  if (!response.ok) throw failure('API', 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.
TARGET_URLEndpoint of your API that stores inbound messages.
TARGET_TOKENBearer token the relay sends to your API.
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
smsIdid
senderfrom
messagetext
receivedAtreceivedAt
idempotencyKeyIdempotency-Key header

Limits to plan for

  • The IETF draft for the Idempotency-Key request header describes it as a way to make non-idempotent methods such as POST fault tolerant. datatracker.ietf.org, checked on September 23, 2026

The relay answers textbee only after your API answers, and textbee stops waiting after a timeout and retries later. Keep the endpoint fast, or queue the message and answer at once.

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

Do I need a relay at all?

Not if your API can read the raw body and verify the HMAC-SHA256 signature in the X-Signature header. Then register the API endpoint as the webhook URL directly. The relay is for APIs you cannot change.

What does the API receive when a delivery is retried?

The same event with the same idempotencyKey, which the relay sends as the Idempotency-Key header. Store it and ignore a repeat.

Can I poll instead of receiving webhooks?

Yes. GET /gateway/messages with direction=received, order=asc and a cursor returns received messages oldest first, which suits a scheduled job or a network that cannot accept inbound requests.

Sources

  1. datatracker.ietf.org, checked on September 23, 2026

Read next