Send email from Go

Five steps: start a module, create a test key, send to the sandbox, read the log, then verify a domain so you can send to anyone.

1. Install

This guide needs no SDK. The API is plain JSON over HTTPS, so net/http and encoding/json from the standard library are all it takes.

shell
mkdir avelto-quickstart && cd avelto-quickstartgo mod init example.com/avelto-quickstart

2. Create an API key

Sign in, open API keys in the dashboard and create a test key. It starts with av_test_. Export it so the program can read it:

shell
export AVELTO_API_KEY=av_test_...

3. Send your first email

Every request carries the key as a bearer token. A rejected send comes back as a non-2xx status with a JSON body of { "error": { "code", "message" } }, decoded here into apiError.

Go
// main.gopackage mainimport (	"bytes"	"encoding/json"	"fmt"	"net/http"	"os")const api = "https://api.avelto.dev"type apiError struct {	Error struct {		Code    string `json:"code"`		Message string `json:"message"`	} `json:"error"`}func main() {	body, _ := json.Marshal(map[string]string{		"from":    "you@sandbox.avelto.dev",		"to":      "delivered@sandbox.avelto.dev",		"subject": "Hello from Avelto",		"text":    "It works.",	})	req, _ := http.NewRequest("POST", api+"/v1/emails", bytes.NewReader(body))	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))	req.Header.Set("Content-Type", "application/json")	res, err := http.DefaultClient.Do(req)	if err != nil {		panic(err)	}	defer res.Body.Close()	if res.StatusCode != http.StatusCreated {		var e apiError		json.NewDecoder(res.Body).Decode(&e)		fmt.Fprintf(os.Stderr, "%d %s: %s\n",			res.StatusCode, e.Error.Code, e.Error.Message)		os.Exit(1)	}	var out struct {		ID string `json:"id"`	}	json.NewDecoder(res.Body).Decode(&out)	fmt.Println(out.ID) // "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10"}
shell
go run .

The API answers 201 Created with the email id:

HTTP
HTTP/1.1 201 CreatedContent-Type: application/json{ "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10" }

4. Check the log

Add this function and call it with the id from step 3. status moves from queued to sent to delivered, and events records each step: email.queued, email.sent, email.delivered.

Go
func getEmail(id string) {	req, _ := http.NewRequest("GET", api+"/v1/emails/"+id, nil)	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))	res, err := http.DefaultClient.Do(req)	if err != nil {		panic(err)	}	defer res.Body.Close()	var email struct {		Status string `json:"status"`		Events []struct {			Type       string `json:"type"`			OccurredAt string `json:"occurred_at"`		} `json:"events"`	}	json.NewDecoder(res.Body).Decode(&email)	fmt.Println(email.Status) // "queued", then "sent", then "delivered"	for _, e := range email.Events {		fmt.Println(e.Type, e.OccurredAt)	}}
JSON
{  "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10",  "mode": "test",  "from": "you@sandbox.avelto.dev",  "to": ["delivered@sandbox.avelto.dev"],  "subject": "Hello from Avelto",  "status": "delivered",  "events": [    {      "id": "e1f0c3a4-8b2d-4c6e-9a1f-5d7b3e2c8a90",      "type": "email.queued",      "payload": {},      "occurred_at": "2026-09-17T10:12:04.000Z"    },    {      "id": "a7c2e9d1-3f4b-4a8e-b6c0-2d9e1f7b5c34",      "type": "email.sent",      "payload": { "test": true, "ses_message_id": "test-9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10" },      "occurred_at": "2026-09-17T10:12:06.000Z"    },    {      "id": "c4b8d2f6-7e1a-4d3c-8f9b-6a2e0c5d1b78",      "type": "email.delivered",      "payload": { "test": true, "recipients": ["delivered@sandbox.avelto.dev"] },      "occurred_at": "2026-09-17T10:12:06.000Z"    }  ]}

5. Verify a domain

Add a domain, publish the DNS records it prints (an ownership TXT, an SPF TXT and a DMARC TXT, then three DKIM CNAMEs once the ownership record is found), then poll GET /v1/domains/:id until status is verified. The GET re-checks DNS on every call. Use a subdomain such as mail.acme.com.

Go
// Add "time" to the imports.func verifyDomain(name string) {	body, _ := json.Marshal(map[string]string{"name": name})	req, _ := http.NewRequest("POST", api+"/v1/domains", bytes.NewReader(body))	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))	req.Header.Set("Content-Type", "application/json")	res, err := http.DefaultClient.Do(req)	if err != nil {		panic(err)	}	defer res.Body.Close()	var domain struct {		ID                string `json:"id"`		Status            string `json:"status"`		OwnershipVerified bool   `json:"ownership_verified"`		DNSRecords        []struct {			Type, Name, Value, Purpose string		} `json:"dns_records"`	}	json.NewDecoder(res.Body).Decode(&domain)	show := func() {		for _, r := range domain.DNSRecords {			fmt.Printf("%s\t%s\t%s\t(%s)\n", r.Type, r.Name, r.Value, r.Purpose)		}	}	show() // the ownership record first, with SPF and DMARC	// Publish the records, then poll. GET re-checks DNS on every call.	// Once the ownership record is found, the DKIM records appear: publish those too.	proven := domain.OwnershipVerified	for domain.Status == "pending" {		time.Sleep(30 * time.Second)		req, _ := http.NewRequest("GET", api+"/v1/domains/"+domain.ID, nil)		req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))		res, err := http.DefaultClient.Do(req)		if err != nil {			panic(err)		}		json.NewDecoder(res.Body).Decode(&domain)		res.Body.Close()		if domain.OwnershipVerified && !proven {			show()		}		proven = domain.OwnershipVerified	}	fmt.Println(domain.Status) // "verified" or "failed"}

Once the domain is verified, switch AVELTO_API_KEY to a live key (av_live_) and change from to an address on it, such as hello@mail.acme.com. Nothing else changes.

Next

  • Send email: every field, attachments, tags, scheduling and idempotency.
  • Webhooks: get events pushed to your app.
  • Test mode: test keys, the sandbox sender and the simulator addresses.