Plans from $9.99/mo.View Plans
S
go
golang
tutorial
api
sms
android

Send SMS from Go: No Twilio, Just Your Android Phone

Send real SMS from Go using textbee and your Android phone as the gateway. Native net/http, struct marshaling, error handling, and bulk send included.

TT

textbee team

5 min read
Share

TL;DR

  • textbee's REST API needs no SDK — Go's standard net/http package handles everything.
  • No external dependencies required: just encoding/json, net/http, and os.
  • Store credentials in environment variables, not source code.
  • The recipients field is a slice — one request, multiple numbers.
  • No per-message API fee: messages send through the SIM in your Android phone.

You don't need a Twilio account or a rented phone number to send SMS from Go. If you have an Android phone with a working SIM, textbee turns it into an SMS gateway your Go code can call over a plain REST API. The message goes out from your actual phone number.

Before you start

  • textbee app installed on your Android device and linked to your account: download and quickstart
  • Device ID and API key from the dashboard at textbee.dev
  • Go 1.18+ (examples use generics-free code, works on any modern Go version)
  • On Android 15 or 16? You may need to manually enable SMS permissions once

The code

Go
package main

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

type smsRequest struct {
	Recipients []string `json:"recipients"`
	Message    string   `json:"message"`
}

type smsResponse struct {
	Data struct {
		Success        bool   `json:"success"`
		Message        string `json:"message"`
		SmsBatchID     string `json:"smsBatchId"`
		RecipientCount int    `json:"recipientCount"`
	} `json:"data"`
}

func sendSMS(deviceID, apiKey, to, message string) (*smsResponse, error) {
	url := fmt.Sprintf(
		"https://api.textbee.dev/api/v1/gateway/devices/%s/send-sms",
		deviceID,
	)

	payload := smsRequest{
		Recipients: []string{to},
		Message:    message,
	}

	body, err := json.Marshal(payload)
	if err != nil {
		return nil, fmt.Errorf("marshal payload: %w", err)
	}

	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("x-api-key", apiKey)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("SMS send failed (HTTP %d): %s", resp.StatusCode, respBody)
	}

	var result smsResponse
	if err := json.Unmarshal(respBody, &result); err != nil {
		return nil, fmt.Errorf("unmarshal response: %w", err)
	}

	return &result, nil
}

func main() {
	deviceID := os.Getenv("TEXTBEE_DEVICE_ID")
	apiKey := os.Getenv("TEXTBEE_API_KEY")

	result, err := sendSMS(deviceID, apiKey, "+15551234567", "Hello from Go!")
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Sent! Batch ID: %s, Recipients: %d\n",
		result.Data.SmsBatchID, result.Data.RecipientCount)
}

Export your credentials and run:

Shell
export TEXTBEE_DEVICE_ID="your-device-id"
export TEXTBEE_API_KEY="your-api-key"
go run main.go

Sending to multiple recipients

Change Recipients in smsRequest to a slice with multiple entries — one API call reaches all of them:

Go
func sendBulkSMS(deviceID, apiKey string, recipients []string, message string) (*smsResponse, error) {
	url := fmt.Sprintf(
		"https://api.textbee.dev/api/v1/gateway/devices/%s/send-sms",
		deviceID,
	)

	payload := smsRequest{
		Recipients: recipients,
		Message:    message,
	}
	// ... rest identical to sendSMS above
}

// Usage
recipients := []string{"+15551234567", "+15559876543", "+15550001111"}
result, err := sendBulkSMS(deviceID, apiKey, recipients, "Appointment reminder: tomorrow at 10am.")

Adding a timeout and context

For production use, always set a timeout on the HTTP client:

Go
import (
	"context"
	"time"
)

func sendSMSWithTimeout(deviceID, apiKey, to, message string) (*smsResponse, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// ... build req as before, then:
	req = req.WithContext(ctx)

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	// ...
}

What the API returns

On success:

{
  "data": {
    "success": true,
    "message": "SMS added to queue for processing",
    "smsBatchId": "abc123xyz",
    "recipientCount": 1
  }
}

success: true means the message was accepted and queued on your device — not yet necessarily delivered to the recipient's handset. The device sends asynchronously over the cellular network. Track delivery status in the textbee dashboard, or configure webhooks for status callbacks.

On failure, you get a non-2xx status code. The sendSMS function above surfaces the response body in the error, which typically contains an error field explaining the cause (bad API key, device offline, invalid number format, etc.).

Packaging as a reusable client

For a real application, wrap the client in a struct:

Go
type TextbeeClient struct {
	deviceID string
	apiKey   string
	http     *http.Client
}

func NewTextbeeClient(deviceID, apiKey string) *TextbeeClient {
	return &TextbeeClient{
		deviceID: deviceID,
		apiKey:   apiKey,
		http:     &http.Client{Timeout: 10 * time.Second},
	}
}

func (c *TextbeeClient) Send(to, message string) (*smsResponse, error) {
	// use c.deviceID, c.apiKey, c.http
}

func (c *TextbeeClient) SendBulk(recipients []string, message string) (*smsResponse, error) {
	// same, with recipients slice
}

Initialize once at startup (NewTextbeeClient(os.Getenv(...), os.Getenv(...))), inject into your handlers, and reuse the HTTP client across requests.

Things worth knowing

Phone number format: Use E.164 (+15551234567). Local formats without a country code may work for same-country sends on some carriers but E.164 is unambiguous and always correct.

No per-message fee: Messages route through your own Android SIM. Your cost is the textbee flat subscription — not a per-message meter. See pricing.

Opt-in matters: Messages come from your real phone number. Read the compliance checklist before sending to lists.

Frequently asked questions

Is there a Go SDK for textbee?

Not yet. The REST API is simple enough — the 60 lines above are the "SDK." The API docs cover all endpoints including inbound webhooks, device status, and batch operations.

How do I handle retries on transient failures?

Wrap sendSMS in a retry loop with exponential backoff. On a 429 or 5xx response, wait and retry up to 3 times. Don't retry on 4xx errors (bad request, auth failure) — those won't succeed on retry.

Can I use this in a Goroutine-heavy service?

Yes. http.DefaultClient is safe for concurrent use, and the TextbeeClient struct above is also safe to share across goroutines since all state is in the request, not the client. Use a context with a timeout to avoid goroutine leaks on slow sends.

What Go version is required?

Go 1.13+ for fmt.Errorf with %w. The code otherwise uses only standard library packages available since Go 1.0. No generics, no module-version-specific features.

Keep going

Download textbee and send your first Go SMS in under 5 minutes.