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.
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.
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.
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.
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
- Send SMS from Go: No Twilio, Just Your Android Phone
- How to Receive SMS and Process Webhooks with textbee
- API reference
- OTP and verification guides
- SMS gateway for the United States
- Webhook, defined
- E.164 phone number format, defined
- Send and receive SMS in Ruby with a REST API
- Send and receive SMS in curl and shell with a REST API
- All languages