Skip to content
Claim your free business email
Email API for Go

Send email from Go with an email 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.

Go — main.go
// 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)}

What sending from Go actually involves

To send email from Go you need net/http, encoding/json and a key — everything else the standard library already gives you. There is no Go SDK, and the sample below shows why one is not urgent: the whole send is a marshalled map, four headers and a decoded response. net/smtp is the alternative, and it means running or renting a relay.

Last updated 2026-10-13. Every field, header and limit below is the one the emails reference carries.

What you get with the key

Five things that arrive with the first request rather than with a later plan.

  • Idempotent sends

    Put an Idempotency-Key on a send and repeat it as often as you like. For 24 hours the same key and payload return the first response instead of a second email, and a different payload under that key is refused rather than sent.

  • Signed webhooks you can replay

    Delivery, bounce, complaint, open and click posted to your endpoint with a timestamp and a signature, retried on failure and replayable from the dashboard.

  • Inbound mail on your own domain

    Receive at your domain on every plan. Each message arrives on a signed webhook with its headers, text and HTML, and its attachments behind signed links.

  • Templates with typed variables

    Publish a template, then send it by ID or alias with the values it declares. Every email records the exact version it was rendered from.

  • Campaigns and automations

    Contacts with typed properties, topics people subscribe to, and per-contact workflows that wait, branch and send — through the same pipeline as the rest of your mail.

How to send email from Go

Six steps, in this order. Everything before the fifth is done once, and only the fifth is about the language you write in.

  1. 1

    Create an API key

    Open API keys in the dashboard and create one. It is shown once and stored hashed, so copy it then. Put it in your environment as RASKET_API_KEY and read it from there; a key committed to a repository is a key you have to rotate.

  2. 2

    Add a sending domain

    POST /domains with a domain you control. A subdomain such as send.acme.example is the usual choice, because it keeps this mail's reputation separate from the address your people write from. The response carries a records array.

  3. 3

    Publish the DNS records

    Every row of records goes into your DNS: the DKIM TXT record at rasket._domainkey, and the CNAME that gives the domain its own return path. Nothing sends from the domain until they resolve.

  4. 4

    Verify the domain

    POST /domains/{domain_id}/verify. The domain comes back with a status on each record, and once it reads verified you can send from any address on it. An unverified sender is refused rather than quietly dropped.

  5. 5

    Send your first email from Go

    Post the message with the sample above. Carry an Idempotency-Key — yours or your queue's retry cannot then turn into a second email — and a User-Agent, which every request has to have. A 200 with an id means we have taken responsibility for the message.

  6. 6

    Subscribe a webhook and check its signature

    POST /webhooks with an https endpoint and the events you care about. Delivery, bounce, complaint, open and click arrive there with svix-id, svix-timestamp and svix-signature; verify against the raw body before anything parses it.

Verify webhooks in Go

io.ReadAll(r.Body) before anything decodes it: json.NewDecoder(r.Body) consumes the reader and the bytes are gone. Hand the slice and r.Header to the svix-webhooks module's Verify, and decode only after it returns nil. Keep a copy of the bytes if you need both the event and the raw source.

The five steps a verifier performs, in seven languages, are on the webhooks reference.

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.

Sending from Go with an API and with net/smtp
An HTTP APInet/smtp
Packagesnet/http, encoding/jsonnet/smtp, plus MIME by hand
Round tripsOneSeveral, by design
What you get backA message id to storeA queue acceptance line
Timeoutshttp.Client.Timeout and a contextYours, on a raw net.Conn
Domain authenticationGenerated and checked for youYours to publish and watch
SuppressionsEnforced at send timeNone
BouncesTyped events on a webhookMessages you parse
Port44325, 465 or 587, often blocked
Best forProduct mail you have to account forA 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.

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-After when 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.

Sources

  1. Package net/http — The Go Programming Language, read 2026-09-16
  2. Package crypto/hmac — The Go Programming Language, read 2026-09-16
  3. RFC 2104: HMAC: Keyed-Hashing for Message Authentication — IETF, read 2026-09-16

Questions about Go

Do I need an SMTP server to send email from Go?

No. net/smtp needs a relay to talk to and a MIME message built by hand; an HTTPS API needs a key and a verified domain, and the send is the twenty lines above. If you already have net/smtp code, Rasket's SMTP relay at smtp.rasket.com takes it, with the key as the password.

Is there a Go SDK?

Not today. The API is plain JSON over HTTPS and the sample above is the whole of a send, so a client is a convenience rather than a requirement. The OpenAPI document is published, if you would rather generate one than write it.

Where do I keep the API key in a Go service?

In the environment, read once at startup with os.Getenv and checked for empty before the first request, so a misconfigured deployment fails immediately rather than collecting 401s. Build the http.Client once and reuse it; the default one is fine.

How do I stop a retry from sending the same email twice?

Set the Idempotency-Key header from whatever the mail is about rather than from a random value, and reuse it when you retry. For 24 hours the same key with the same payload returns the first response instead of sending again, which is what makes an at-least-once queue safe here.

How do I verify a Rasket webhook in Go?

go get github.com/svix/svix-webhooks/go, read the body with io.ReadAll before anything decodes it, and call Verify with those bytes and r.Header. It checks the three svix headers and the timestamp window; decode the JSON only once it has returned without an error.

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.

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.

Make your first send

Create a key, verify a domain, and post your first message from Go. The free plan does not expire.