How to send email from Go with net/http: a client, typed errors, retries and webhooks
Published Updated 12 min readBy the Rasket team

net/smtp versus an API
To send email from Go you either speak SMTP yourself through net/smtp, or you make one HTTPS request with net/http. A Go email API client is that one request wrapped in a type. Both hand a message to a server that will deliver it. The difference is what that server does for you afterwards, and how much of the protocol you end up owning.
| An HTTP API | net/smtp | |
|---|---|---|
| Packages | net/http, encoding/json | net/smtp, plus MIME by hand |
| Round trips | One | Several, by design |
| What you get back | A message id to store | A queue acceptance line |
| Timeouts | http.Client.Timeout and a context | Yours, on a raw net.Conn |
| Domain authentication | Generated and checked for you | Yours to publish and watch |
| Suppressions | Enforced at send time | None |
| Bounces | Typed events on a webhook | Messages you parse |
| Port | 443 | 25, 465 or 587, often blocked |
| Best for | Product mail you have to account for | A relay you already run inside your own network |
net/smtp is frozen: the package documentation says it is not accepting new features, and it has no MIME builder, no attachment support and no retry policy. None of that is a complaint, since it was written to talk to a relay, not to be a sending platform. The API versus SMTP comparison goes through the trade in full; the rest of this guide takes the API side and builds a small client on it.
A minimal client with net/http
This is the sample from the Go stack page, unchanged. It is the whole API in forty lines: a JSON body, four headers, and an id back.
// net/http and encoding/json are all you need.package main
import ( "bytes" "encoding/json" "fmt" "net/http" "os")
const url = "https://api.rasket.com/emails"
func main() { payload, err := json.Marshal(map[string]any{ "from": "Acme <orders@send.acme.example>", "to": []string{"ronald.williams@example.com"}, "subject": "Your order has shipped", "html": "<p>Order 1042 shipped today.</p>", }) if err != nil { panic(err) }
body := bytes.NewReader(payload) req, err := http.NewRequest("POST", url, body) if err != nil { panic(err) }
key := os.Getenv("RASKET_API_KEY") req.Header.Set("Authorization", "Bearer "+key) // Required on every request. req.Header.Set("User-Agent", "acme-billing/1.0") req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "order-1042")
res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close()
var email struct { ID string `json:"id"` } err = json.NewDecoder(res.Body).Decode(&email) if err != nil { panic(err) }
fmt.Println(email.ID)}Three of the four headers are load-bearing. Authorization carries the key, read from RASKET_API_KEY rather than written into the source. User-Agent is required: a request without one is refused, and the value is what lets support tell your billing worker from your web process. Idempotency-Key is derived from the order rather than generated fresh, which is what makes the retry section below safe.
- Add and verify a sending domain — Add the domain the mail will come from, publish the DNS records Rasket generates for it, and wait for verification to pass. A send from an unverified domain is refused.
- Put the key in the environment — Create an API key in the dashboard and export it as RASKET_API_KEY. Read it with os.Getenv at start-up and refuse to build the client when it is empty, so a missing key fails before the first send.
- Build the request with the standard library — Marshal the message with encoding/json, wrap the bytes in a bytes.Reader, and create the request with http.NewRequestWithContext so a deadline from the caller can cancel it.
- Set the four headers — Authorization carries the bearer key, User-Agent names your service and is required, Content-Type is application/json, and Idempotency-Key is derived from the thing that caused the send, such as the order id.
- Decode the response and branch on the error name — A 200 carries the email id to store. Anything else carries statusCode, name and message: decode it into a typed error, read Retry-After on a 429, and retry only a 429, a 5xx or a lost connection.
- Verify webhooks before you trust them — Read the raw body with io.ReadAll, compute HMAC-SHA256 over id.timestamp.body with crypto/hmac, compare with hmac.Equal, reject a timestamp more than 300 seconds old, and only then decode the JSON.
The from address has to be on a verified domain
A send from a domain that has not passed verification is refused before anything leaves. Publishing the records takes a few minutes and the verification walkthrough covers what each one is for.
A client with a timeout and a context
The first sample uses http.DefaultClient, which has no timeout. That is fine at a terminal and wrong in a worker: a connection that stalls after the TLS handshake holds the goroutine until the far end closes it, which may be never. Wrap the pieces in a struct that owns its own http.Client and takes a context.Context on every call.
package rasket
import ( "bytes" "context" "encoding/json" "errors" "net/http" "os" "time")
const baseURL = "https://api.rasket.com"
// Client sends email through the Rasket API.type Client struct { key string http *http.Client}
// New reads the key from the environment and fails at start-up if it is missing.func New() (*Client, error) { key := os.Getenv("RASKET_API_KEY") if key == "" { return nil, errors.New("RASKET_API_KEY is not set") } return &Client{ key: key, http: &http.Client{Timeout: 10 * time.Second}, }, nil}
// Message is the body of POST /emails.type Message struct { From string `json:"from"` To []string `json:"to"` Subject string `json:"subject"` HTML string `json:"html,omitempty"` Text string `json:"text,omitempty"`}
// Send posts one message. The idempotency key names the thing that caused the send.func (c *Client) Send(ctx context.Context, msg Message, idempotencyKey string) (string, error) { payload, err := json.Marshal(msg) if err != nil { return "", err }
req, err := http.NewRequestWithContext( ctx, http.MethodPost, baseURL+"/emails", bytes.NewReader(payload), ) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+c.key) req.Header.Set("User-Agent", "acme-billing/1.0") req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := c.http.Do(req) if err != nil { return "", err } defer res.Body.Close()
if res.StatusCode != http.StatusOK { return "", decodeError(res) }
var email struct { ID string `json:"id"` } if err := json.NewDecoder(res.Body).Decode(&email); err != nil { return "", err } return email.ID, nil}Two limits, doing two jobs. http.Client.Timeout caps one request from dial to the end of the body, and it is the floor under everything. The context is the caller’s budget for the whole operation — context.WithTimeout(ctx, 15*time.Second) at the call site — and http.NewRequestWithContext is what lets a cancelled context abandon a request midway instead of waiting the timeout out.
New refuses to build a client without a key, so a missing variable fails at start-up rather than sending an empty bearer token at three in the morning. The Message struct covers the fields a receipt needs; cc, bcc, reply_to, tags, attachments and template follow the same pattern, and the emails reference lists every one with its limits.
Build one Client per process and share it: an http.Client pools its connections, and the struct is safe for concurrent use.
Handling the response and errors
Every refused request carries the same body: statusCode, name and message, plus an errors array on a validation_error naming the field that failed. Decode it once, into a type that satisfies error, and the rest of the program can branch on name with errors.As.
// Same package as the client. Add "fmt" and "strconv" to its imports.
// APIError is the body every refused request carries.type APIError struct { StatusCode int `json:"statusCode"` Name string `json:"name"` Message string `json:"message"` Errors []struct { Path string `json:"path"` Message string `json:"message"` } `json:"errors,omitempty"` // Seconds to wait, from the Retry-After header. Only set on a 429. RetryAfter int `json:"-"`}
func (e *APIError) Error() string { return fmt.Sprintf("rasket: %d %s: %s", e.StatusCode, e.Name, e.Message)}
func decodeError(res *http.Response) error { apiErr := &APIError{StatusCode: res.StatusCode} if err := json.NewDecoder(res.Body).Decode(apiErr); err != nil { apiErr.Name = "unknown" apiErr.Message = res.Status } if apiErr.Name == "rate_limit_exceeded" { if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil { apiErr.RetryAfter = seconds } } return apiErr}The Retry-After read is deliberate. It is present only on a 429, it is whole seconds and never zero, and strconv.Atoi is the honest way to read it: a header that fails to parse leaves RetryAfter at zero and the retry loop falls back to its own backoff. The json:"-" tag keeps the decoder from touching the field.
id, err := client.Send(ctx, msg, "order-1042")if err != nil { var apiErr *rasket.APIError if errors.As(err, &apiErr) { // The API answered and said no. switch apiErr.Name { case "validation_error": log.Printf("payload rejected: %v", apiErr.Errors) // a retry fails the same way case "rate_limit_exceeded": log.Printf("rate limited, retry in %ds", apiErr.RetryAfter) default: log.Printf("refused: %v", apiErr) } return err } // Nothing answered: a timeout or a connection error. Retry with the SAME key. log.Printf("no answer: %v", err) return err}log.Printf("accepted as %s", id)The two branches are the whole retry policy. An *APIError means the API answered; only rate_limit_exceeded and a 5xx are worth waiting on, because a validation_error or a missing_required_field will fail identically forever. Any other error means nothing answered, so you do not know whether the message was recorded — which is the case the next section exists for. The errors guide lists every name, and the rate limits guide the headers on every response.
Retrying safely with an Idempotency-Key
A retry is only safe when a repeat is recognisable. The Idempotency-Key header is remembered for 24 hours: the same key with the same payload replays the first response and sends nothing, and the same key with a different payload is refused. So keep the key fixed across attempts, and derive it from the thing that caused the send.
// Same package again, with "fmt" and "math/rand/v2" imported.
// SendWithRetry repeats Send on a 429, a 5xx and on no answer at all,// with the same idempotency key on every attempt.func (c *Client) SendWithRetry(ctx context.Context, msg Message, idempotencyKey string) (string, error) { backoff := 500 * time.Millisecond for attempt := 1; ; attempt++ { id, err := c.Send(ctx, msg, idempotencyKey) if err == nil { return id, nil }
wait := backoff var apiErr *APIError if errors.As(err, &apiErr) { if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode < 500 { return "", err // any other 4xx will not change on retry } if apiErr.RetryAfter > 0 { wait = time.Duration(apiErr.RetryAfter) * time.Second } } if attempt == 5 { return "", fmt.Errorf("giving up after %d attempts: %w", attempt, err) }
// Jitter, so a fleet of workers does not retry in step. math/rand/v2. wait += rand.N(wait / 4) select { case <-time.After(wait): case <-ctx.Done(): return "", ctx.Err() } backoff *= 2 }}- Which errors repeat. A
429, anything 5xx, and no answer at all. Every other 4xx returns immediately, since the payload is what is wrong. - How long to wait.
Retry-Afterwhen the API sent one, otherwise a doubling backoff from half a second, with jitter so that fifty workers refused together do not return together. - When to stop. Five attempts, and the context’s deadline at any point. A deadline that fires mid-wait returns
ctx.Err()rather than sleeping it out.
The subtle case is the last one. If attempt one timed out after the API had recorded the message, attempt two carries the same key, is recognised as a replay, and returns the original id without sending a second receipt. Without the key, the same loop is a machine for sending duplicates. The idempotent sends guide works through the timing in detail.
Verifying webhooks by hand
Delivery events arrive as signed HTTPS requests. The scheme is Standard Webhooks: three headers, svix-id, svix-timestamp and svix-signature, and a signature that is HMAC-SHA256 over the string id.timestamp.body, keyed with the secret after its whsec_ prefix is stripped and the rest base64-decoded. The standard library has every piece.
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "math" "net/http" "os" "strconv" "strings" "time")
// verify checks one Standard-Webhooks signature: HMAC-SHA256 over "id.timestamp.body".func verify(secret, id, timestamp string, body []byte, signatures string) bool { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false }
sent, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 { return false // older than five minutes, or from the future: a replay }
mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + "." + string(body))) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// "v1,<sig> v1,<sig>": two entries for 24 hours after a secret rotation. for _, entry := range strings.Fields(signatures) { version, sig, ok := strings.Cut(entry, ",") if !ok || version != "v1" { continue } if hmac.Equal([]byte(sig), []byte(expected)) { return true } } return false}
func webhook(w http.ResponseWriter, r *http.Request) { // Read the raw bytes BEFORE anything decodes them. The signature is over what arrived. body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return }
ok := verify( os.Getenv("RASKET_WEBHOOK_SECRET"), r.Header.Get("svix-id"), r.Header.Get("svix-timestamp"), body, r.Header.Get("svix-signature"), ) if !ok { http.Error(w, "invalid signature", http.StatusBadRequest) return }
var event struct { Type string `json:"type"` } if err := json.Unmarshal(body, &event); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return }
w.WriteHeader(http.StatusOK) // answer first, work after go record(r.Header.Get("svix-id"), event.Type, body)}What each check is for
- Raw body first.
io.ReadAll(r.Body)runs before any decoding; a re-marshalled body is a different string with a different signature. - The timestamp window. A valid signature older than 300 seconds is refused, or a captured request could be replayed for ever.
- Constant-time compare.
hmac.Equal, never==. - Several signatures. The header holds space-separated
v1,<sig>entries — two for 24 hours after a secret rotation — and any one matching is enough.
Answer 200 as soon as the signature checks out and do the work afterwards, deduplicating on svix-id, because a handler that times out will be sent the same event again and events can arrive out of order. If you would rather not maintain the HMAC yourself, the github.com/svix/svix-webhooks/go module implements the same scheme: svix.NewWebhook(secret) then wh.Verify(body, r.Header), with the raw body read exactly as above. The webhooks article covers retries, ordering and replay, and the webhooks reference lists every event type.
Frequently asked questions
Do I need a Go SDK to send email from Go?
No. The API is one HTTPS request with a JSON body and four headers, and net/http plus encoding/json cover it in about forty lines. The client struct in this guide adds a timeout, a typed error and a retry loop, which is most of what a package would give you, without a dependency to track.
Why is net/smtp not enough?
It is a working SMTP client, but the package is frozen and does nothing beyond the protocol: no MIME builder, no attachments, no domain verification, no suppression list and no delivery events. If you already run a relay inside your own network it is fine; for product mail you have to account for, an API does the parts net/smtp leaves to you.
What timeout should the http.Client have?
Ten seconds is a sensible cap for one request: a send normally answers well inside a second, and anything much longer is a stalled connection rather than a slow one. Pair it with a context from the caller so that the whole operation, retries included, also has a deadline the request can be cancelled against.
Which errors are safe to retry?
A 429 rate_limit_exceeded after the Retry-After seconds, any 5xx, and no answer at all, which in Go is any error from client.Do. A validation_error or another 4xx will fail identically forever, so return it. Whatever you retry, send the same Idempotency-Key, because that is what turns a repeat into a replay instead of a second email.
How do I verify a webhook signature in Go without a library?
Strip the whsec_ prefix from the secret and base64-decode what is left to get the key. Compute HMAC-SHA256 over the string svix-id, a dot, svix-timestamp, a dot, and the raw body bytes, base64-encode the result, and compare it with hmac.Equal against each v1 entry in svix-signature. Reject anything whose timestamp is more than 300 seconds from now.
Why must the webhook body be read before decoding?
Because the signature is over the exact bytes that arrived. If a JSON decoder consumes the request body first, you are left re-encoding a struct, and the re-encoded string almost never matches byte for byte, so a valid signature fails. Read with io.ReadAll, verify those bytes, then json.Unmarshal the same slice.
Sources
- Package net/http — The Go Programming Language, read 2026-09-16
- Package crypto/hmac — The Go Programming Language, read 2026-09-16
- RFC 2104: HMAC: Keyed-Hashing for Message Authentication — IETF, read 2026-09-16
Related
- Send email from Go with an API — Send email from Go with net/http and encoding/json: one POST to the API, an id back, and delivery events on a signed webhook.
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Idempotency — Retry a send without sending it twice.
- Email webhooks explained: events and signatures — What an email webhook is, the delivery events and what fires each, the payload shape, verifying the signature, and handling retries and out-of-order events.
- Webhooks — Payloads, signature verification, retries and replay.