How to Send OTP in Go (Golang) — 2026 Developer Guide
Step-by-step Go (Golang) tutorial to send and verify SMS OTPs using the StartMessaging API. Includes net/http examples, structs, error handling, and a complete Gin server.
StartMessaging Team
Engineering
Go is a popular choice for high-throughput OTP backends thanks to its simple concurrency model and tiny memory footprint. In this guide you’ll wire up the StartMessaging OTP API from a Go service using only the standard library — no third-party SDK required.
Why Use Go for OTP Backends
OTP traffic is bursty: thousands of users may try to log in at the same instant during a marketing push or app launch. Go’s goroutines let a single small VM handle that fan-out comfortably, and the static binary makes deployment trivial. Pair that with a DLT-free OTP API and you can ship a production OTP flow in an afternoon.
Prerequisites
- Go 1.21 or newer.
- A free StartMessaging account with wallet credit.
- An API key generated from the API Keys page.
# .env
STARTMESSAGING_API_KEY=sm_live_xxxxxxxxxxxxxxxxxxxxSend an OTP from Go
Define small request and response structs, then POST JSON to /otp/send:
package otp
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
"time"
)
const baseURL = "https://api.startmessaging.com"
type sendReq struct {
PhoneNumber string `json:"phoneNumber"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
type SendResponse struct {
Data struct {
RequestID string `json:"requestId"`
ExpiresAt time.Time `json:"expiresAt"`
AttemptsLeft int `json:"attemptsLeft"`
} `json:"data"`
}
var client = &http.Client{Timeout: 10 * time.Second}
func Send(phone, idemKey string) (*SendResponse, error) {
body, _ := json.Marshal(sendReq{PhoneNumber: phone, IdempotencyKey: idemKey})
req, _ := http.NewRequest("POST", baseURL+"/otp/send", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("STARTMESSAGING_API_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, errors.New("startmessaging: send failed status " + resp.Status)
}
var out SendResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}Verify the OTP
type verifyReq struct {
RequestID string `json:"requestId"`
OtpCode string `json:"otpCode"`
}
type VerifyResponse struct {
Data struct {
Verified bool `json:"verified"`
} `json:"data"`
}
func Verify(requestID, code string) (bool, error) {
body, _ := json.Marshal(verifyReq{RequestID: requestID, OtpCode: code})
req, _ := http.NewRequest("POST", baseURL+"/otp/verify", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("STARTMESSAGING_API_KEY"))
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
var out VerifyResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
return out.Data.Verified, nil
}Full Gin Server Example
package main
import (
"net/http"
"yourapp/otp"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func main() {
r := gin.Default()
r.POST("/auth/send-otp", func(c *gin.Context) {
var body struct{ PhoneNumber string `json:"phoneNumber"` }
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
res, err := otp.Send(body.PhoneNumber, uuid.NewString())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"requestId": res.Data.RequestID,
"expiresAt": res.Data.ExpiresAt,
})
})
r.POST("/auth/verify-otp", func(c *gin.Context) {
var body struct {
RequestID string `json:"requestId"`
OtpCode string `json:"otpCode"`
}
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
ok, err := otp.Verify(body.RequestID, body.OtpCode)
if err != nil || !ok {
c.JSON(http.StatusUnauthorized, gin.H{"verified": false})
return
}
c.JSON(http.StatusOK, gin.H{"verified": true})
})
r.Run(":8080")
}Error Handling & Retries
Wrap the Send call in a small retry helper that backs off on 5xx and 429 responses but never retries 4xx — those will not succeed on retry. Combine this with idempotency keys so a retried network request never bills the wallet twice.
Best Practices
- Validate phone numbers server-side using libphonenumber or a regex before calling the API.
- Set short HTTP timeouts (8–10 seconds) to avoid blocked goroutines.
- Hide the API key behind environment variables and never log it.
- Track verify attempts in your own DB so you can lock accounts after repeated failures — see OTP rate limiting guide.
FAQ
Compare costs in our OTP API pricing comparison or jump straight to StartMessaging pricing at Rs 0.25 per OTP.
Related Articles
Step-by-step Node.js tutorial to send and verify OTP via SMS using the StartMessaging API. Includes fetch examples, error handling, and verification flow.
Python tutorial to send and verify OTP via SMS using the requests library and StartMessaging API. Includes Flask and Django integration examples.
Spring Boot 3 + RestClient calling a TRAI-compliant OTP SMS API: JSON, env-based keys, and patterns for DLT-backed transactional SMS from JVM backends.
Ready to Send OTPs?
Integrate StartMessaging in 5 minutes. No DLT registration required.