# Send Email with Go (Golang): API Tutorial

> Send email with Go using net/http and an email API: a dependency-free client with retries, templates, attachments, batches of 100 and an HTTP handler.

Canonical: https://www.cresca.xyz/blog/send-email-golang
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

To **send email with Go** you can use `net/smtp`, but the standard library itself calls that package frozen, and SMTP brings connection handling, TLS and opaque errors. An email API needs only `net/http` and `encoding/json`: one request, an email id back, and delivery from your own domain within seconds. This tutorial builds a small, dependency-free Go client with retries and idempotency, then uses it for templates, attachments, batches and an HTTP handler.

**TL;DR**

- POST JSON to `https://api.cresca.xyz/api/v1/emails` with `Authorization: Bearer <key>`.
- Standard library only: net/http, encoding/json, context.
- Retry 429, 5xx and network errors with an `Idempotency-Key` so a retry never sends twice.
- Batch up to 100 emails in one request.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

The code needs Go 1.21 or later and nothing outside the standard library. The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Send email with Go: the basic request

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

func main() {
	payload, err := json.Marshal(map[string]any{
		"from":    "Acme <hello@acme.com>",
		"to":      "ana@example.com",
		"subject": "Welcome to Acme",
		"html":    "<p>Hi Ana, your account is ready.</p>",
	})
	if err != nil {
		log.Fatal(err)
	}

	req, err := http.NewRequest(http.MethodPost, "https://api.cresca.xyz/api/v1/emails", bytes.NewReader(payload))
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CRESCA_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	res, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	var body struct {
		ID      string `json:"id"`
		Name    string `json:"name"`
		Message string `json:"message"`
	}
	if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
		log.Fatal(err)
	}
	if res.StatusCode != http.StatusOK {
		log.Fatalf("%s: %s", body.Name, body.Message)
	}
	fmt.Println(body.ID)
}
```

The response is `200 OK` with `{"id": "4ef9a417-..."}`. Always set a client timeout: the zero-value `http.Client` waits forever. `json.Marshal` escapes `<` and `>` as unicode sequences, which is valid JSON and arrives unchanged.

## A small Go client with retries

The basic request is fine for a script. In production you want three more things, and the `cresca` package below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```go
// Package cresca is a small client for the Cresca email API.
package cresca

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"net/http"
	"os"
	"strconv"
	"time"
)

const (
	apiURL   = "https://api.cresca.xyz/api/v1"
	attempts = 4
)

var client = &http.Client{Timeout: 10 * time.Second}

type Email struct {
	From        string       `json:"from,omitempty"`
	To          []string     `json:"to"`
	ReplyTo     string       `json:"reply_to,omitempty"`
	Subject     string       `json:"subject,omitempty"`
	HTML        string       `json:"html,omitempty"`
	Text        string       `json:"text,omitempty"`
	Template    *Template    `json:"template,omitempty"`
	Attachments []Attachment `json:"attachments,omitempty"`
}

type Template struct {
	ID        string         `json:"id"`
	Variables map[string]any `json:"variables,omitempty"`
}

type Attachment struct {
	Filename    string `json:"filename"`
	Content     []byte `json:"content"` // encoding/json sends []byte as base64
	ContentType string `json:"content_type,omitempty"`
}

// Error is an error response from the API. Branch on Name.
type Error struct {
	Status  int
	Name    string `json:"name"`
	Message string `json:"message"`
}

func (e *Error) Error() string {
	return fmt.Sprintf("cresca: %d %s: %s", e.Status, e.Name, e.Message)
}

// Post sends payload to path, retrying where it is safe, and decodes the
// response into out.
func Post(ctx context.Context, path string, payload any, idempotencyKey string, out any) error {
	data, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	for attempt := 1; ; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL+path, bytes.NewReader(data))
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+os.Getenv("CRESCA_API_KEY"))
		req.Header.Set("Content-Type", "application/json")
		if idempotencyKey != "" {
			req.Header.Set("Idempotency-Key", idempotencyKey)
		}

		wait, retryable, err := do(req, out)
		if err == nil {
			return nil
		}
		if !retryable || attempt == attempts {
			return err
		}
		if wait == 0 {
			// 1s, 2s, 4s... plus up to 250 ms of jitter.
			wait = time.Duration(1<<(attempt-1))*time.Second + time.Duration(rand.Intn(250))*time.Millisecond
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(wait):
		}
	}
}

func do(req *http.Request, out any) (wait time.Duration, retryable bool, err error) {
	res, err := client.Do(req)
	if err != nil {
		return 0, true, err // network error or timeout
	}
	defer res.Body.Close()

	if res.StatusCode == http.StatusOK {
		return 0, false, json.NewDecoder(res.Body).Decode(out)
	}
	apiErr := &Error{Status: res.StatusCode}
	_ = json.NewDecoder(res.Body).Decode(apiErr) // a proxy error may have no JSON body
	retryable = res.StatusCode >= 500 ||
		(res.StatusCode == http.StatusTooManyRequests && apiErr.Name != "monthly_quota_exceeded") ||
		apiErr.Name == "concurrent_idempotent_requests"
	if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
		wait = time.Duration(seconds) * time.Second
	}
	return wait, retryable, apiErr
}
```

Using it:

```go
var sent struct {
	ID string `json:"id"`
}
err := cresca.Post(ctx, "/emails", cresca.Email{
	From:    "Acme <hello@acme.com>",
	To:      []string{user.Email},
	Subject: "Welcome to Acme",
	HTML:    "<p>Hi " + html.EscapeString(user.FirstName) + ", your account is ready.</p>",
}, "welcome/"+user.ID, &sent)

var apiErr *cresca.Error
if errors.As(err, &apiErr) && apiErr.Name == "invalid_from_address" {
	// Configuration problem: alert someone instead of retrying.
}
```

`html.EscapeString` keeps user input from changing your markup, and `errors.As` lets you branch on the API's error `Name`.

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

```go
err := cresca.Post(ctx, "/emails", cresca.Email{
	To: []string{user.Email},
	Template: &cresca.Template{
		ID:        "password-reset", // the template's alias or id
		Variables: map[string]any{"NAME": user.FirstName, "RESET_LINK": resetURL},
	},
}, "password-reset/"+resetRequestID, &sent)
```

## Add attachments

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

In Go you can skip the manual encoding: `encoding/json` writes a `[]byte` field as standard base64, which is exactly what the API expects.

```go
pdf, err := os.ReadFile("invoices/inv-2026-0042.pdf")
if err != nil {
	return err
}
err = cresca.Post(ctx, "/emails", cresca.Email{
	From:    "Acme Billing <billing@acme.com>",
	To:      []string{customer.Email},
	Subject: "Your invoice INV-2026-0042",
	HTML:    "<p>Thanks for your payment. Your invoice is attached.</p>",
	Attachments: []cresca.Attachment{
		{Filename: "inv-2026-0042.pdf", Content: pdf, ContentType: "application/pdf"},
	},
}, "invoice/inv-2026-0042", &sent)
```

## Send a batch of up to 100 emails

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

```go
emails := make([]cresca.Email, 0, len(users))
for _, u := range users {
	emails = append(emails, cresca.Email{
		From:    "Acme <hello@acme.com>",
		To:      []string{u.Email},
		Subject: "Your weekly summary",
		HTML:    renderSummary(u),
	})
}

for i := 0; i < len(emails); i += 100 {
	chunk := emails[i:min(i+100, len(emails))]
	var result struct {
		Data []struct {
			ID string `json:"id"`
		} `json:"data"`
	}
	key := fmt.Sprintf("weekly-summary/2026-10-27/%d", i/100)
	if err := cresca.Post(ctx, "/emails/batch", chunk, key, &result); err != nil {
		return err
	}
	log.Printf("batch %d: %d emails accepted", i/100, len(result.Data))
}
```

## Send email from an HTTP handler

Pass the request's context so a send stops when the client disconnects, or detach it with `context.WithoutCancel` or a queue when the email must go out regardless:

```go
func signupHandler(w http.ResponseWriter, r *http.Request) {
	user, err := createUser(r) // your own code
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Detach from the request so a closed browser tab does not cancel the email.
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
	defer cancel()

	var sent struct {
		ID string `json:"id"`
	}
	err = cresca.Post(ctx, "/emails", cresca.Email{
		To:       []string{user.Email},
		Template: &cresca.Template{ID: "welcome", Variables: map[string]any{"NAME": user.FirstName}},
	}, "welcome/"+user.ID, &sent)
	if err != nil {
		log.Printf("welcome email for %s failed: %v", user.ID, err)
	}

	w.WriteHeader(http.StatusCreated)
}
```

## net/smtp or an email API?

The Go documentation describes `net/smtp` as frozen and not accepting new features, and points to third-party packages. Libraries such as go-mail fill that gap if you run an SMTP server. With an API you keep to the standard library, get errors as JSON with a stable `name`, and get idempotent retries for free. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown here.

## Errors to handle when you send email with Go

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send email in Golang without SMTP?

Call an email API with net/http. With Cresca you marshal a struct with from, to, subject and html to JSON and POST it to https://api.cresca.xyz/api/v1/emails with your API key as a bearer token. You need nothing outside the standard library.

### Is there an official Cresca SDK for Go?

No, and you do not need one. The API is plain HTTPS and JSON, so the HTTP client you already use in Go works. The helper in this tutorial is about 40 lines you can copy into your project and own.

### How do I send attachments in Go?

Read the file with os.ReadFile and put the bytes in a \[\]byte field tagged json:"content". encoding/json encodes \[\]byte as standard base64, which is the format the API expects. Up to 20 files and 7 MB in total per email.

### Is the client safe to use from many goroutines?

Yes. http.Client is safe for concurrent use and the package keeps no other shared state. Your plan's requests-per-second limit applies across all goroutines and servers, so use batch sending when you have many emails to send at once.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Node.js](https://www.cresca.xyz/blog/send-email-nodejs) · [Python](https://www.cresca.xyz/blog/send-email-python) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [C# and .NET](https://www.cresca.xyz/blog/send-email-csharp) · [Next.js](https://www.cresca.xyz/blog/send-email-nextjs) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [Django](https://www.cresca.xyz/blog/send-email-django) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with Go in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
