# 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.

**shell**

```bash
mkdir avelto-quickstart && cd avelto-quickstart
go mod init example.com/avelto-quickstart
```

## 2. Create an API key

[Sign in](/login), 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**

```bash
export AVELTO_API_KEY=av_test_...
```

> **Sandbox rules.** 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`.

**Go**

```go
// 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"
}
```

**shell**

```bash
go run .
```

The API answers `201 Created` with the email id:

```http
HTTP/1.1 201 Created
Content-Type: application/json

{ "id": "9c1f4a52-6f6e-4b8f-9b8e-2e1a5c7d3f10" }
```

> **Retrying safely.** 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](/docs/send-email).

## 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**

```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 (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`.

**Go**

```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"`
		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](/docs/send-email): every field, attachments, tags, scheduling and idempotency.
- [Webhooks](/docs/webhooks): get events pushed to your app.
- [Test mode](/docs/test-mode): test keys, the sandbox sender and the simulator addresses.

---

Rendered page: https://avelto.dev/docs/quickstart/go
