← Back to blog

Send OTP Emails in Go (2026) with a Plain HTTP API

Build signup/login OTP in Go: crypto/rand, HMAC-hashed codes in sync.Map/Redis/SQL, rate limits, net/http or chi to POST /emails, and Idempotency-Key—prefer HTTP over SMTP; Rovela delivers only.

Updated November 4, 2026
Send OTP Emails in Go (2026) with a Plain HTTP API

Why Go + OTP codes (not a magic-link SDK)

Go already gives signup and login a boring, reliable shape: net/http (or chi) handlers, middleware for rate limits, and a server-side store (sync.Map, Redis, or SQL). You do not need a Rovela Go SDK, an “OTP product” endpoint, or a middleware that pretends the email API is an identity provider.

What you need is the recipe in Email verification codes: your app mints a six-digit code with crypto/rand, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POST /emails with both html and text via the standard library net/http client. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.

Rovela does not provide an OTP feature. Your Go service owns cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.

This article is Go focused (1.22+ friendly handlers, works the same on chi / Echo / Gin): handlers for “send code” and “verify code,” a thin Rovela client, an in-memory or Redis HMAC store, and an Idempotency-Key so a timeout retry does not enqueue two disagreeing codes. It is not a magic-link walkthrough—for those see Next.js, NestJS, Rails, and FastAPI. Sibling OTP posts: Express.js, Django, Laravel. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email. Generic Go transport (not OTP): Sending email in Go—this post does not rehash SMTP dialers, MIME builders, or one-off “hello world” sends.

What you own vs what Rovela sends

Your Go service owns:

  • crypto/rand → six digits, zero-padded (never math/rand)
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) via crypto/hmac + crypto/sha256
  • Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
  • Upsert by normalized email so a new send invalidates the previous code (sync.Map, Redis, or SQL)
  • Constant-time compare on redeem (hmac.Equal); delete / invalidate on success
  • Identical public responses whether or not you want to reveal account existence (product choice)
  • Rate limits on /auth/otp/send and /auth/otp/verify

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in env—never commit it, never put it in a public config, never log it)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Env and a tiny Rovela client (net/http)

// config.go
package otp

import (
	"os"
	"strconv"
	"time"
)

type Config struct {
	APIKey           string
	CodePepper       []byte
	From             string
	TTL              time.Duration
	ResendCooldown    time.Duration
	MaxAttempts      int
}

func LoadConfig() Config {
	ttlMin, _ := strconv.Atoi(envOr("OTP_TTL_MINUTES", "10"))
	coolSec, _ := strconv.Atoi(envOr("OTP_RESEND_COOLDOWN_SEC", "60"))
	maxAtt, _ := strconv.Atoi(envOr("OTP_MAX_ATTEMPTS", "5"))
	return Config{
		APIKey:        mustEnv("ROVELA_API_KEY"), // sending_access
		CodePepper:    []byte(mustEnv("CODE_PEPPER")),
		From:          envOr("OTP_FROM", "Acme <security@mail.acme.com>"),
		TTL:           time.Duration(ttlMin) * time.Minute,
		ResendCooldown: time.Duration(coolSec) * time.Second,
		MaxAttempts:   maxAtt,
	}
}

func mustEnv(k string) string {
	v := os.Getenv(k)
	if v == "" {
		panic("missing env " + k)
	}
	return v
}

func envOr(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}
// rovela.go
package otp

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

type MailClient struct {
	HTTP   *http.Client
	APIKey string
	From   string
}

type SendInput struct {
	To             string
	Subject        string
	HTML           string
	Text           string
	IdempotencyKey string
}

func (c *MailClient) Send(ctx context.Context, in SendInput) (map[string]any, error) {
	body, _ := json.Marshal(map[string]string{
		"from":    c.From,
		"to":      in.To,
		"subject": in.Subject,
		"html":    in.HTML,
		"text":    in.Text,
	})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.rovela.dev/emails", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.APIKey)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", in.IdempotencyKey)
	req.Header.Set("Accept", "application/json")

	client := c.HTTP
	if client == nil {
		client = &http.Client{Timeout: 15 * time.Second}
	}
	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	raw, _ := io.ReadAll(res.Body)
	if res.StatusCode >= 300 {
		return nil, fmt.Errorf("rovela send failed: %d %s", res.StatusCode, string(raw))
	}
	var out map[string]any
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil, err
	}
	return out, nil
}

Wire ROVELA_API_KEY and CODE_PEPPER like any other server secret (Authentication, Quick Start). A sending_access key is enough—and preferred—for production OTP senders.

Hash at rest: crypto/rand + HMAC, not bare SHA-256

Six digits are only a million possibilities. A leaked table of bare SHA-256 hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server.

// crypto.go
package otp

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math/big"
	"strings"
)

func NormalizeEmail(raw string) string {
	return strings.ToLower(strings.TrimSpace(raw))
}

func MintCode() (string, error) {
	// CSPRNG — math/rand is not for secrets
	n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%06d", n.Int64()), nil
}

func HashCode(pepper []byte, email, code string) string {
	mac := hmac.New(sha256.New, pepper)
	mac.Write([]byte(email + ":" + code))
	return hex.EncodeToString(mac.Sum(nil))
}

func SafeEqualHex(a, b string) bool {
	ab, err1 := hex.DecodeString(a)
	bb, err2 := hex.DecodeString(b)
	if err1 != nil || err2 != nil || len(ab) != len(bb) {
		return false
	}
	return hmac.Equal(ab, bb)
}

Suggested row shape (one active code per email), matching the verification-code example:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

In-memory sketch for demos: a sync.Map keyed by email works for a single process. Use Redis or Postgres when you run multiple replicas.

Service: send + verify (sync.Map store)

// service.go
package otp

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"
)

type record struct {
	CodeHash  string
	Attempts  int
	ExpiresAt time.Time
	SentAt    time.Time
}

type Service struct {
	Cfg   Config
	Mail  *MailClient
	store sync.Map // email -> record
}

const safeSendMsg = "If that address can receive mail, a code is on its way."

func (s *Service) Send(ctx context.Context, rawEmail string) (map[string]any, error) {
	email := NormalizeEmail(rawEmail)
	now := time.Now().UTC()

	if v, ok := s.store.Load(email); ok {
		rec := v.(record)
		if now.Sub(rec.SentAt) < s.Cfg.ResendCooldown {
			return map[string]any{"ok": false, "error": "too_soon"}, nil
		}
	}

	code, err := MintCode()
	if err != nil {
		return nil, err
	}
	codeHash := HashCode(s.Cfg.CodePepper, email, code)
	s.store.Store(email, record{
		CodeHash:  codeHash,
		Attempts:  0,
		ExpiresAt: now.Add(s.Cfg.TTL),
		SentAt:    now,
	})

	// Stable per logical send: hash prefix. New code ⇒ new key.
	idem := "otp-" + codeHash[:32]
	ttlMin := int(s.Cfg.TTL.Minutes())
	_, err = s.Mail.Send(ctx, SendInput{
		To:      email,
		Subject: "Your Acme code: " + code,
		HTML: "<p>Your verification code is</p>" +
			`<p style="font-size:28px;font-weight:600;letter-spacing:4px">` + code + `</p>` +
			fmt.Sprintf("<p>It expires in %d minutes. If you did not ask for it, ignore this email.</p>", ttlMin),
		Text: fmt.Sprintf(
			"Your verification code is %s. It expires in %d minutes. If you did not ask for it, ignore this email.",
			code, ttlMin,
		),
		IdempotencyKey: idem,
	})
	if err != nil {
		return nil, err
	}
	return map[string]any{"ok": true, "message": safeSendMsg}, nil
}

func (s *Service) Verify(rawEmail, rawCode string) map[string]any {
	email := NormalizeEmail(rawEmail)
	code := strings.TrimSpace(rawCode)
	if len(code) != 6 || !allDigits(code) {
		return map[string]any{"ok": false, "error": "invalid"}
	}

	v, ok := s.store.Load(email)
	if !ok {
		return map[string]any{"ok": false, "error": "invalid"}
	}
	rec := v.(record)
	now := time.Now().UTC()
	if now.After(rec.ExpiresAt) {
		s.store.Delete(email)
		return map[string]any{"ok": false, "error": "expired"}
	}
	if rec.Attempts >= s.Cfg.MaxAttempts {
		return map[string]any{"ok": false, "error": "locked"}
	}

	if !SafeEqualHex(HashCode(s.Cfg.CodePepper, email, code), rec.CodeHash) {
		rec.Attempts++
		s.store.Store(email, rec)
		return map[string]any{"ok": false, "error": "invalid"}
	}

	s.store.Delete(email)
	// issue session / JWT here
	return map[string]any{"ok": true}
}

func allDigits(s string) bool {
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

Putting the code in the subject helps lock-screen previews—fine for many products; skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails).

For Redis: SET otp:{email} {json} with TTL = expiry, plus SET otp:cooldown:{email} 1 EX 60. For SQL: INSERT … ON CONFLICT (email) DO UPDATE, bump attempts inside a transaction, DELETE on success.

Handlers: net/http or chi + rate limits

// handlers.go
package otp

import (
	"encoding/json"
	"net/http"
)

type Handlers struct {
	Svc *Service
}

func (h *Handlers) Send(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Email string `json:"email"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Email == "" {
		http.Error(w, `{"error":"invalid_email"}`, http.StatusBadRequest)
		return
	}
	out, err := h.Svc.Send(r.Context(), body.Email)
	if err != nil {
		http.Error(w, `{"error":"send_failed"}`, http.StatusBadGateway)
		return
	}
	if ok, _ := out["ok"].(bool); !ok {
		if out["error"] == "too_soon" {
			http.Error(w, `{"error":"too_soon"}`, http.StatusTooManyRequests)
			return
		}
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{"message": out["message"]})
}

func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Email string `json:"email"`
		Code  string `json:"code"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		http.Error(w, `{"error":"invalid_json"}`, http.StatusBadRequest)
		return
	}
	out := h.Svc.Verify(body.Email, body.Code)
	if ok, _ := out["ok"].(bool); !ok {
		status := http.StatusBadRequest
		if out["error"] == "locked" || out["error"] == "too_soon" {
			status = http.StatusTooManyRequests
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		_ = json.NewEncoder(w).Encode(map[string]any{"error": out["error"]})
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// main.go (net/http stdlib)
package main

import (
	"log"
	"net/http"

	"example.com/app/otp"
)

func main() {
	cfg := otp.LoadConfig()
	svc := &otp.Service{
		Cfg:  cfg,
		Mail: &otp.MailClient{APIKey: cfg.APIKey, From: cfg.From},
	}
	h := &otp.Handlers{Svc: svc}

	mux := http.NewServeMux()
	mux.Handle("POST /auth/otp/send", withIPLimit(http.HandlerFunc(h.Send), 10))
	mux.Handle("POST /auth/otp/verify", withIPLimit(http.HandlerFunc(h.Verify), 20))
	log.Fatal(http.ListenAndServe(":8080", mux))
}
// chi mount (optional)
// r := chi.NewRouter()
// r.Use(middleware.RealIP, middleware.Recoverer)
// r.With(ipLimit(10)).Post("/auth/otp/send", h.Send)
// r.With(ipLimit(20)).Post("/auth/otp/verify", h.Verify)

Implement withIPLimit / ipLimit with a token bucket per IP (or put Cloudflare / nginx in front). Per-email cooldown inside the service and per-IP limits solve different problems—use both. Hardening checklist: Email OTP security.

Idempotency-Key: retries without double codes

Workers, reverse proxies, and AI agents retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.

  • Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
  • Derive the key from the code hash (otp- + first 32 hex chars) so one logical mint maps to one queued message.
  • A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-go-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

Why not SMTP dialers for OTP in Go

net/http → POST /emails (this post)net/smtp / MIME libraries
TransportHTTPS 443465 / 587 (25 often blocked)
IdempotencyIdempotency-Key headerApp must dedupe
FeedbackMessage id + webhooksBounce DSNs later
Containers / Cloud RunPort 443 always openSMTP often restricted
OTP fitJSON html + textHand-built MIME

Use SMTP when you already depend on it. For new signup/login digits, call the HTTP API. Broader Go transport patterns live in Sending email in Go.

Deliverability notes for auth digits (not magic URLs)

OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.

If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.

OTP codes vs magic links in Go

Six-digit OTP (this post)Magic link
User actionType digitsClick URL
Secret entropyLow → HMAC + pepper requiredHigh URL token → SHA-256 of token is usually enough
Docs shapeVerification codesPassword reset / link pattern
Go surfacePOST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
Click trackingOptional concernPrefer off on the security sending domain

Both call the same POST /emails. Keep separate stores—do not reuse an OTP record as a magic-link token.

Checklist before you ship

  1. crypto/rand + HMAC pepper; never store raw codes; never use math/rand for OTP.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limit.
  3. Upsert / overwrite invalidates the previous code; verify deletes on success.
  4. html and text; optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only on the server.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Prefer HTTP API over SMTP for OTP; verified from-domain; spam checklist if inbox placement slips.

Related reading

  1. Docs: Email verification codes · POST /emails · Quick Start · Authentication.
  2. Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
  3. UX choice: Email verification: code vs link.
  4. Deliverability: Why OTP and magic-link emails go to spam.
  5. Framework siblings: Express OTP · Django OTP · Laravel OTP · magic-link Next.js / NestJS / Rails / FastAPI.
  6. Go transport only: Sending email in Go.

Bottom line: put signup/login OTP in Go handlers that mint crypto/rand codes, HMAC them at rest (sync.Map, Redis, or SQL), rate-limit send/verify, and call one net/http POST to Rovela with an Idempotency-Key—prefer that over SMTP dialers, and never pretend the email API is your auth server.