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.

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
sendEmailcall; a hung HTTP connection blocks a goroutine indefinitely without one. - Rate limit handling: the API returns
429with{"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.ConstantTimeComparefor signature bytes, not==orbytes.Equal.