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

Log received SMS to Google Sheets automatically

Published and updated

Add a short Apps Script to the spreadsheet and deploy it as a web app, then run a relay that verifies the textbee webhook and posts each text to that web app. Every received SMS becomes a row with the time, the sender and the message.

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 the spreadsheet and a tab named SMS with the headers Received, From and Message.
  2. Open Extensions, then Apps Script, paste the script below, and set a script property RELAY_TOKEN to a long random value.
  3. Deploy it as a web app that runs as you, with access for anyone, and copy the web app URL that ends in /exec.
  4. Give the relay the same RELAY_TOKEN, so only your relay can write rows.

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

async function forward(event) {
  // Apps Script answers the POST with a redirect; fetch follows it to the script output.
  const response = await fetch(APPS_SCRIPT_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    signal: AbortSignal.timeout(20_000),
    body: JSON.stringify({
      token: RELAY_TOKEN,
      receivedAt: event.receivedAt,
      sender: event.sender,
      message: event.message,
    }),
  })
  const output = await response.text()
  if (!response.ok) throw failure('Apps Script', response.status)
  if (output !== 'ok') throw new Error(`Apps Script answered ${output}`)
}

// 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.
APPS_SCRIPT_URLWeb app URL of the deployed script, ending in /exec.
RELAY_TOKENSame value as the RELAY_TOKEN script property.
PORTPort the relay listens on. Defaults to 3000.

The Apps Script

Apps Script cannot read request headers, so it cannot check the textbee signature itself. The relay checks it and passes a shared token instead. Every value gets a leading apostrophe, so a text cannot run a formula in your sheet and numbers keep their leading zeros.

Code.gs
const TOKEN = PropertiesService.getScriptProperties().getProperty('RELAY_TOKEN')

// A leading apostrophe keeps a value as plain text: no formulas, no lost leading zeros.
function asText(value) {
  return "'" + value
}

function doPost(e) {
  const data = JSON.parse(e.postData.contents)
  if (!TOKEN || data.token !== TOKEN) return ContentService.createTextOutput('forbidden')

  SpreadsheetApp.getActiveSpreadsheet()
    .getSheetByName('SMS')
    .appendRow([new Date(data.receivedAt), asText(data.sender), asText(data.message)])
  return ContentService.createTextOutput('ok')
}

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
receivedAtColumn A, as a date
senderColumn B, as text
messageColumn C, as text

Limits to plan for

  • appendRow adds a row below the current data, and a cell value that begins with = is read as a formula. developers.google.com, checked on September 23, 2026
  • Content service output is redirected to a one time URL, so the HTTP client has to follow redirects. developers.google.com, checked on September 23, 2026
  • Apps Script allows 30 simultaneous executions per user and 6 minutes per execution. developers.google.com, checked on September 23, 2026

Apps Script always answers with HTTP 200, so the relay checks the body for "ok" instead. Each new deployment of the script gets a new URL unless you edit the existing deployment, so update APPS_SCRIPT_URL after redeploying.

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 textbee post straight to the Apps Script without a relay?

It can reach the URL, but the script cannot read the X-Signature header, so anyone who finds the URL could write rows. The relay verifies the signature first, which is why it sits in between.

Why do some cells start with an apostrophe?

Sheets reads a value that starts with = or + as a formula, and turns 0911234567 or TRUE into a number or a boolean. The apostrophe keeps the sender and the text exactly as they arrived, and it is hidden in the cell.

Can I log sent messages too?

Yes. Subscribe the webhook to MESSAGE_SENT or MESSAGE_DELIVERED as well and write those events to another tab. Their payload carries recipient and status instead of sender.

Sources

  1. developers.google.com, checked on September 23, 2026
  2. developers.google.com, checked on September 23, 2026
  3. developers.google.com, checked on September 23, 2026

Read next