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

Send and receive SMS in Go with a REST API

Published and updated

You need an API key and one Android phone with a SIM registered as a device. From Go you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.

Prerequisites

  • A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
  • An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
  • Go 1.18 or newer. The samples use only the standard library and run with go run send.go; no module dependencies are needed.

Send an SMS

One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.

send.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func baseURL() string {
	if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" {
		return url
	}
	return "https://api.textbee.dev/api/v1"
}

func main() {
	payload, _ := json.Marshal(map[string]any{
		"recipients": []string{"+12015550123"},
		"message":    "Hello from textbee",
	})
	request, _ := http.NewRequest(http.MethodPost, baseURL()+"/gateway/send-sms", bytes.NewReader(payload))
	request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY"))
	request.Header.Set("Content-Type", "application/json")

	response, err := http.DefaultClient.Do(request)
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("HTTP %d", response.StatusCode))
	}

	var result struct {
		Data struct {
			SmsBatchID     string `json:"smsBatchId"`
			RecipientCount int    `json:"recipientCount"`
		} `json:"data"`
	}
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		panic(err)
	}
	fmt.Println(result.Data.SmsBatchID, result.Data.RecipientCount)
}

Receive SMS with a webhook

textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.

webhook.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sync"
)

var secret = []byte(os.Getenv("TEXTBEE_WEBHOOK_SECRET"))
var seen sync.Map // use your database in production

type event struct {
	WebhookEvent   string `json:"webhookEvent"`
	IdempotencyKey string `json:"idempotencyKey"`
	Sender         string `json:"sender"`
	Message        string `json:"message"`
}

func handle(writer http.ResponseWriter, request *http.Request) {
	rawBody, _ := io.ReadAll(request.Body)
	mac := hmac.New(sha256.New, secret)
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(request.Header.Get("X-Signature"))) {
		writer.WriteHeader(http.StatusUnauthorized)
		return
	}

	var received event
	if err := json.Unmarshal(rawBody, &received); err != nil {
		writer.WriteHeader(http.StatusBadRequest)
		return
	}
	if _, duplicate := seen.LoadOrStore(received.IdempotencyKey, true); !duplicate && received.WebhookEvent == "MESSAGE_RECEIVED" {
		fmt.Printf("%s: %s\n", received.Sender, received.Message)
	}
	writer.WriteHeader(http.StatusOK)
}

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	http.HandleFunc("/", handle)
	panic(http.ListenAndServe(":"+port, nil))
}

Poll for new messages with a cursor

The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.

poll.go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
)

type page struct {
	Data []struct {
		ID      string `json:"_id"`
		Sender  string `json:"sender"`
		Message string `json:"message"`
	} `json:"data"`
	Meta struct {
		NextCursor string `json:"nextCursor"`
		HasMore    bool   `json:"hasMore"`
	} `json:"meta"`
}

func baseURL() string {
	if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" {
		return url
	}
	return "https://api.textbee.dev/api/v1"
}

func fetchPage(params url.Values) (page, error) {
	var result page
	request, _ := http.NewRequest(http.MethodGet, baseURL()+"/gateway/messages?"+params.Encode(), nil)
	request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY"))
	response, err := http.DefaultClient.Do(request)
	if err != nil {
		return result, err
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		return result, fmt.Errorf("HTTP %d", response.StatusCode)
	}
	return result, json.NewDecoder(response.Body).Decode(&result)
}

func main() {
	params := url.Values{"direction": {"received"}, "order": {"asc"}, "limit": {"50"}, "from": {"2026-09-01T00:00:00Z"}}
	for {
		current, err := fetchPage(params)
		if err != nil {
			panic(err)
		}
		for _, message := range current.Data {
			fmt.Println(message.ID, message.Sender, message.Message)
		}
		if !current.Meta.HasMore {
			break
		}
		params.Set("cursor", current.Meta.NextCursor) // store it to resume the next poll
	}
}

Handle errors

A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.

errors.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

func baseURL() string {
	if url := os.Getenv("TEXTBEE_BASE_URL"); url != "" {
		return url
	}
	return "https://api.textbee.dev/api/v1"
}

func sendSms(payload []byte, retried bool) (string, error) {
	request, _ := http.NewRequest(http.MethodPost, baseURL()+"/gateway/send-sms", bytes.NewReader(payload))
	request.Header.Set("x-api-key", os.Getenv("TEXTBEE_API_KEY"))
	request.Header.Set("Content-Type", "application/json")
	response, err := http.DefaultClient.Do(request)
	if err != nil {
		return "", err
	}
	defer response.Body.Close()
	body, _ := io.ReadAll(response.Body)

	if response.StatusCode == http.StatusOK {
		return string(body), nil
	}
	var detail struct {
		Message string `json:"message"`
	}
	json.Unmarshal(body, &detail)
	switch {
	case response.StatusCode == http.StatusTooManyRequests && !retried:
		time.Sleep(2 * time.Second) // a plan limit or a burst; one retry after a pause
		return sendSms(payload, true)
	case response.StatusCode == http.StatusUnauthorized:
		return "", fmt.Errorf("API key rejected: %s", detail.Message)
	case response.StatusCode == http.StatusBadRequest:
		return "", fmt.Errorf("request rejected: %s", detail.Message)
	}
	return "", fmt.Errorf("HTTP %d: %s", response.StatusCode, detail.Message)
}

func main() {
	payload, _ := json.Marshal(map[string]any{"recipients": []string{"+12015550123"}, "message": "Hello from textbee"})
	result, err := sendSms(payload, false)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println(result)
}

Using it in Gin

In a Gin handler read the raw body with c.GetRawData() before binding, verify the HMAC, then json.Unmarshal into your event struct and c.Status(200). Put the send call behind an interface so tests can point it at an httptest.Server.

Honest limits

One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.

Frequently asked questions

Is there a Go client for textbee?

No official one. net/http and encoding/json cover everything the API needs, as the samples show.

How should I test the send call in Go?

Point TEXTBEE_BASE_URL at an httptest.Server that returns the documented response. The published samples are checked the same way.

Can I run the gateway itself in Go?

The gateway is an Android phone running the textbee app. Your Go service is a client of the API that drives that phone.

Read next