Tutorials

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 Updated

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

# .env
STARTMESSAGING_API_KEY=sm_live_xxxxxxxxxxxxxxxxxxxx

Send 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.

S

StartMessaging Team

StartMessaging Team

Related posts