← Back to blog

Sending Email in Go (2026): net/http, errgroup, and Webhook Verification

Send transactional email in Go using only the standard library: HTTP requests, idempotent retries, concurrent batch sends with errgroup, SMTP, file attachments, and HMAC-SHA256 webhook verification.

Updated November 3, 2026
Sending Email in Go (2026): net/http, errgroup, and Webhook Verification

Go is a natural fit for email automation work: the standard library ships everything you need for HTTP and SMTP, goroutines make it easy to fan out to many recipients without a framework, and the type system keeps your request structs honest. This guide covers sending transactional email through the Rovela API using only the Go standard library, retrying safely with Idempotency-Key, fanning out with errgroup, attaching files, and verifying webhook signatures — all patterns you can drop straight into production.

Prerequisites

  • Go 1.21 or newer
  • A Rovela API key from the dashboard
  • For concurrent batch sends: go get golang.org/x/sync/errgroup

Defining the Request Types

Start with structs that mirror the Rovela /emails JSON body.

package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"time"
)

const rovelaURL = "https://api.rovela.dev/emails"

type Attachment struct {
	Filename    string `json:"filename"`
	Content     string `json:"content"`       // base64-encoded bytes
	ContentType string `json:"content_type"`
}

type SendRequest struct {
	From        string       `json:"from"`
	To          []string     `json:"to"`
	Subject     string       `json:"subject"`
	HTML        string       `json:"html,omitempty"`
	Text        string       `json:"text,omitempty"`
	ReplyTo     string       `json:"reply_to,omitempty"`
	Attachments []Attachment `json:"attachments,omitempty"`
}

type EmailResponse struct {
	ID string `json:"id"`
}

type APIError struct {
	StatusCode int    `json:"statusCode"`
	Name       string `json:"name"`
	Message    string `json:"message"`
}

func (e *APIError) Error() string {
	return fmt.Sprintf("rovela %d %s: %s", e.StatusCode, e.Name, e.Message)
}

Sending a Single Email

The core function builds an HTTP request, sets the Authorization header, and decodes the response. Pass a non-empty idempotencyKey to enable safe retries; pass an empty string for one-shot sends.

func sendEmail(
	ctx context.Context,
	client *http.Client,
	apiKey string,
	req SendRequest,
	idempotencyKey string,
) (*EmailResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, err
	}

	httpReq, err := http.NewRequestWithContext(
		ctx, http.MethodPost, rovelaURL, bytes.NewReader(body),
	)
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Authorization", "Bearer "+apiKey)
	httpReq.Header.Set("Content-Type", "application/json")
	if idempotencyKey != "" {
		httpReq.Header.Set("Idempotency-Key", idempotencyKey)
	}

	resp, err := client.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		var apiErr APIError
		json.NewDecoder(resp.Body).Decode(&apiErr)
		apiErr.StatusCode = resp.StatusCode
		return nil, &apiErr
	}

	var result EmailResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}

Usage:

func main() {
	apiKey := os.Getenv("ROVELA_API_KEY")
	ctx := context.Background()
	client := &http.Client{Timeout: 10 * time.Second}

	result, err := sendEmail(ctx, client, apiKey, SendRequest{
		From:    "hello@yourcompany.com",
		To:      []string{"customer@example.com"},
		Subject: "Welcome to the platform",
		HTML:    "<h1>You're in.</h1><p>Click below to get started.</p>",
	}, "")
	if err != nil {
		fmt.Println("send failed:", err)
		return
	}
	fmt.Println("sent:", result.ID)
}

Retries with Idempotency-Key

The Rovela API accepts an Idempotency-Key header on POST /emails. Within a 24-hour window, repeating the same key returns the cached response instead of sending a duplicate. Generate the key once before the loop so every retry uses the same value.

func sendWithRetry(
	ctx context.Context,
	client *http.Client,
	apiKey string,
	req SendRequest,
	maxAttempts int,
) (*EmailResponse, error) {
	// Generate once; reuse on every retry so the send is idempotent.
	idempotencyKey := fmt.Sprintf("%s-%d", req.To[0], time.Now().UnixNano())

	for attempt := 0; attempt < maxAttempts; attempt++ {
		result, err := sendEmail(ctx, client, apiKey, req, idempotencyKey)
		if err == nil {
			return result, nil
		}

		// Only retry on 429 Too Many Requests.
		var apiErr *APIError
		if !errors.As(err, &apiErr) || apiErr.StatusCode != 429 {
			return nil, err
		}

		backoff := time.Duration(1<<uint(attempt)) * time.Second
		select {
		case <-time.After(backoff):
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
	return nil, fmt.Errorf("exceeded %d attempts", maxAttempts)
}

The key is scoped to the recipient address and a nanosecond timestamp. That is unique enough for transactional sends while still being stable across retries in the same call.

Batch Sends with errgroup

errgroup from golang.org/x/sync combines a sync.WaitGroup with error propagation and cancellation. SetLimit caps the number of goroutines in flight, which matters for rate limits.

import "golang.org/x/sync/errgroup"

func sendBatch(ctx context.Context, apiKey string, reqs []SendRequest) error {
	client := &http.Client{Timeout: 10 * time.Second}

	g, gCtx := errgroup.WithContext(ctx)
	g.SetLimit(5) // at most five concurrent in-flight sends

	for i, req := range reqs {
		i, req := i, req // capture for the goroutine closure
		g.Go(func() error {
			_, err := sendWithRetry(gCtx, client, apiKey, req, 3)
			return err
		})
	}

	return g.Wait()
}

If any goroutine returns an error, gCtx is cancelled and g.Wait() returns that error after all running goroutines have finished. The batch endpoint (POST /emails/batch) supports up to 100 recipients in one call and is useful when all recipients share the same subject and body; use the fan-out pattern above when each recipient needs a personalised message or an attachment.

Sending via SMTP

For use cases where you need to drive SMTP directly — such as routing through a custom domain relay — Go's crypto/tls and net/smtp packages handle the TLS handshake without dependencies.

import (
	"crypto/tls"
	"net/smtp"
)

func sendViaSMTP(smtpHost, username, password, from, to, subject, body string) error {
	auth := smtp.PlainAuth("", username, password, smtpHost)

	conn, err := tls.Dial("tcp", smtpHost+":465", &tls.Config{ServerName: smtpHost})
	if err != nil {
		return err
	}

	c, err := smtp.NewClient(conn, smtpHost)
	if err != nil {
		return err
	}
	defer c.Close()

	if err = c.Auth(auth); err != nil {
		return err
	}
	if err = c.Mail(from); err != nil {
		return err
	}
	if err = c.Rcpt(to); err != nil {
		return err
	}

	w, err := c.Data()
	if err != nil {
		return err
	}

	msg := fmt.Sprintf(
		"From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s",
		from, to, subject, body,
	)
	if _, err = fmt.Fprint(w, msg); err != nil {
		return err
	}
	return w.Close()
}

Find your SMTP host and port in the Rovela dashboard under Domains → SMTP settings. The credentials are the same API key you use for the REST API.

File Attachments

Add an Attachments slice to SendRequest and base64-encode the file contents. Attachments are not supported on the batch endpoint; send them through POST /emails one at a time.

func buildInvoiceEmail(apiKey, recipient, invoicePath string) SendRequest {
	data, err := os.ReadFile(invoicePath)
	if err != nil {
		panic(err)
	}
	return SendRequest{
		From:    "billing@yourcompany.com",
		To:      []string{recipient},
		Subject: "Your invoice",
		HTML:    "<p>Your invoice is attached.</p>",
		Attachments: []Attachment{
			{
				Filename:    "invoice.pdf",
				Content:     base64.StdEncoding.EncodeToString(data),
				ContentType: "application/pdf",
			},
		},
	}
}

Webhook Signature Verification

Rovela signs webhook deliveries with HMAC-SHA256. The signature is in the x-resend-signature header in the format t=<timestamp>,v1=<hex-signature>. The signed string is the timestamp, a literal dot, and the raw request body: <timestamp>.<raw-body>.

Use crypto/subtle.ConstantTimeCompare to compare the signatures, which avoids timing attacks that a plain string comparison would expose.

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strings"
)

// VerifyWebhook reads the body, verifies the signature, and returns
// the raw body so the caller can decode it. It restores r.Body so
// subsequent reads in the handler work normally.
func VerifyWebhook(r *http.Request, secret string) ([]byte, error) {
	sigHeader := r.Header.Get("x-resend-signature")
	if sigHeader == "" {
		return nil, fmt.Errorf("missing x-resend-signature")
	}

	var timestamp, v1 string
	for _, part := range strings.Split(sigHeader, ",") {
		switch {
		case strings.HasPrefix(part, "t="):
			timestamp = strings.TrimPrefix(part, "t=")
		case strings.HasPrefix(part, "v1="):
			v1 = strings.TrimPrefix(part, "v1=")
		}
	}
	if timestamp == "" || v1 == "" {
		return nil, fmt.Errorf("malformed x-resend-signature header")
	}

	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, err
	}
	r.Body = io.NopCloser(bytes.NewReader(rawBody))

	// signed string = "<timestamp>.<raw-body>"
	signed := timestamp + "." + string(rawBody)

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(signed))
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(v1)
	if err != nil {
		return nil, fmt.Errorf("invalid signature hex: %w", err)
	}

	if subtle.ConstantTimeCompare(got, expected) != 1 {
		return nil, fmt.Errorf("signature mismatch")
	}
	return rawBody, nil
}

Wire it into an HTTP handler:

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	secret := os.Getenv("ROVELA_WEBHOOK_SECRET")
	body, err := VerifyWebhook(r, secret)
	if err != nil {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	var event map[string]any
	if err := json.Unmarshal(body, &event); err != nil {
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	fmt.Printf("received event: %s\n", event["type"])
	w.WriteHeader(http.StatusNoContent)
}

Production Checklist

  • API key in environment: read from ROVELA_API_KEY; never hard-code it or commit it to version control.
  • Idempotency keys: generate them once before the retry loop, not inside it.
  • Context and timeouts: pass a context with a deadline to every sendEmail call; a hung HTTP connection blocks a goroutine indefinitely without one.
  • Rate limit handling: the API returns 429 with {"statusCode":429,"name":"Too Many Requests"} when you exceed the send limit; the retry loop above handles it with exponential backoff.
  • errgroup limit: set g.SetLimit(n) to a value lower than your Rovela rate limit divided by the expected per-send latency.
  • Webhook secret rotation: store the signing secret in an environment variable; update it in the Rovela dashboard and in your deployment atomically.
  • Constant-time comparison: always use subtle.ConstantTimeCompare for signature bytes, not == or bytes.Equal.