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
No SDK needed. The API is plain JSON over HTTPS, so net/http and encoding/json from the standard library are all it takes.
mkdir avelto-quickstart && cd avelto-quickstart
go mod init example.com/avelto-quickstart2. 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:
export AVELTO_API_KEY=av_test_...Test keys never deliver anything; they run the pipeline and record events. The sandbox sender you@sandbox.avelto.dev only delivers to your account's verified owner email and to the simulator addresses delivered@, bounced@ and complained@sandbox.avelto.dev. Anything else is refused with 403 sandbox_recipient_not_allowed. To send to anyone, verify a domain (step 5).
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.
// main.go
package main
import (
"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"
}go run .The API answers 201 Created with the email id:
HTTP/1.1 201 Created
Content-Type: application/json
{ "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10" }Send an Idempotency-Key header (any unique string, such as your order id) with every POST /v1/emails. If the request times out or comes back 429, 502, 503 or 504, wait a moment and send it again unchanged with the same key: the API returns the original email id instead of sending twice. See Idempotency.
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.
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)
}
}{
"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 (three DKIM CNAMEs, an SPF TXT and a DMARC TXT), 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.
// 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"`
DNSRecords []struct {
Type, Name, Value, Purpose string
} `json:"dns_records"`
}
json.NewDecoder(res.Body).Decode(&domain)
for _, r := range domain.DNSRecords {
fmt.Printf("%s\t%s\t%s\t(%s)\n", r.Type, r.Name, r.Value, r.Purpose)
}
// Publish the records, then poll. GET re-checks DNS on every call.
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()
}
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.