net/http makes the request, context bounds it, and
encoding/json handles the payload. No SDK means no dependency to audit, no version to chase, and
code that moves to any provider by changing one URL. We target SMSRoute endpoints below. SMSRoute is a no-KYC
SMS API with crypto billing (BTC, ETH, USDT, XMR, LTC, and SOL), so signup to first send is minutes. But the shape is universal.
Why stdlib is the whole toolkit here
Why use only Go standard library for SMS API calls?
Using Go's standard library eliminates external dependencies, reduces build complexity, and ensures long-term maintainability. SMSRoute's REST API works seamlessly with net/http, making integration straightforward. You get production-grade HTTP client capabilities without third-party packages, keeping your codebase lean and auditable.
To send SMS with Go you need nothing beyond the standard library. net/http makes
the request, context bounds it, and encoding/json handles the payload. No SDK means no
dependency to audit, no version to chase, and code that moves to any provider by changing one URL. We target
SMSRoute endpoints below. SMSRoute is a no-KYC SMS API with crypto billing (BTC, ETH, USDT, XMR, LTC, and SOL),
so signup to first send is minutes. But the shape is
universal.
package sms
import (
"bytes"; "context"; "encoding/json"; "fmt"; "net/http"; "os"; "time"
)
func Send(ctx context.Context, to, text string) (string, error) {
body, _ := json.Marshal(map[string]string{"to": to, "from": "YourSenderID", "message": text})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://api.smsroute.cc/sms/send", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SMSROUTE_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("send failed: %d", resp.StatusCode)
}
// Response schema not specified. Handle generically.
var out map[string]interface{}
json.NewDecoder(resp.Body).Decode(&out)
return fmt.Sprintf("%v", out), nil
}
Two Go-idiomatic habits are baked in. The key comes from os.Getenv, never a literal. And the
request takes a context.Context, so the caller controls the timeout and cancellation. A hung OTP
send should never block a request goroutine.
Timeouts and retries the Go way
How to implement SMS API timeouts and retries in Go?
Go's context package and net/http client provide built-in timeout and retry mechanisms. Set a context deadline per request, then implement exponential backoff with jitter for retries. SMSRoute's API responds reliably within these patterns, and our 99.9% uptime means retries rarely trigger, keeping your code efficient.
In Go, timeouts belong on the context, and retries belong to failures where the message definitely did not send. Never retry a success; never retry a 4xx.
func SendWithRetry(to, text string, tries int) (id string, err error) {
for i := 0; i < tries; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
id, err = Send(ctx, to, text)
cancel()
if err == nil { return id, nil }
// retry only timeouts and 5xx; 4xx means fix the request
if ctx.Err() == nil && !is5xx(err) { return "", err }
time.Sleep(time.Duration(500*(1<<i)) * time.Millisecond) // 0.5s,1s,2s
}
return "", err
}
| Failure | Retry? | Why |
|---|---|---|
| Context deadline / network | Yes, backoff | The request may never have landed |
| 5xx | Yes, backoff | Provider-side, pre-acceptance |
| 4xx (bad number, auth) | No | Retry re-fails identically. Fix the input |
| Success (id returned) | Never | Message is out; retry double-sends |
Cap total time for OTP. Three retries with backoff already spends ~4 seconds; past that, hand the user a resend button rather than keep the goroutine grinding. The UX ladder from our OTP best-practices guide. Our OTP best practices guide recommends capping retries at three with exponential backoff, then offering a resend button. See the full guide at https://example.com/otp-best-practices.
DLR webhook, idempotent by default
How to handle SMS delivery reports (DLR) idempotently in Go?
SMSRoute sends real-time DLR webhooks with unique message IDs. In Go, store processed IDs in a map or database to skip duplicates. Use sync.Mutex for thread safety. This idempotent pattern ensures your application correctly tracks delivery status even if webhooks arrive multiple times.
A returned id means accepted, not delivered. The delivery receipt arrives later by webhook. Handle it fast and idempotently.
func dlrHandler(w http.ResponseWriter, r *http.Request) {
var dlr struct{ ID, Status string }
json.NewDecoder(r.Body).Decode(&dlr)
markDelivery(dlr.ID, dlr.Status) // keyed on ID. DLRs can repeat.
w.WriteHeader(http.StatusOK) // ack immediately, process async
}
- Ack with 200 first, do work after. A slow handler gets callbacks throttled.
- Key on the message id. Duplicate and out-of-order DLRs are normal.
- Never let an old status overwrite a terminal one. Model a small state machine, not last-write-wins.
- Record delivered-minus-sent per country. Your real latency, per the OTP latency method.
Testing offline with an interface
How to test SMS sending code offline in Go?
Define an interface for your SMS client, then implement a mock that returns predefined responses. SMSRoute's API structure maps cleanly to this pattern. Your production code uses the real HTTP client, while tests use the mock. No network needed. This approach validates retry logic, error handling, and DLR processing without sending actual messages.
Go's interfaces make the fake trivial. Define a Sender interface, inject a real client in
production and a fake in tests. No per-commit spend, full coverage of the failure branches.
type Sender interface{ Send(ctx context.Context, to, text string) (string, error) }
type fakeSender struct{}
func (fakeSender) Send(_ context.Context, _, _ string) (string, error) {
return "test_123", nil // deterministic, free, offline
}
Assert on response shape (an id came back, no error), prove the retry path sends once on a 500-then-200 sequence, and confirm duplicate DLRs update state once. Reserve a single real send to your own phone as the pre-release smoke test. The full pattern is in our test-numbers guide. Production cost is per message and per destination. Budget with the international cost guide. SMSRoute's published route pages list delivery from $0.004/message (premium direct-carrier corridors up to $0.035) with sub-100ms median submission and ~98.6% delivered success (smsroute.cc route pages, 2026). For authoritative guidance on retry strategies, see IETF RFC 7231 Section 6.3.1 on idempotent methods.
Related reading
FAQ
How do I send an SMS in Go without a third-party SDK?
How should I add timeouts to Go SMS calls?
When should a Go SMS send be retried?
How do I test SMS sending in Go without paying?
Send your first SMS in 5 minutes
No KYC. Pay with BTC, ETH, USDT, XMR, LTC, and SOL. Live routes to 149 countries.
Get an API key →