Notix
Guides

Send email from Go with one API call.

By the end of this guide you will have sent a real email from a Go program through the email API with net/http and nothing else, sent one more through the SMTP relay with net/smtp, and added what a production send needs: a context with a deadline, a retry that cannot double-send, and a webhook that verifies its signature.

What you need

Three things, none of them a card.

An API key.

Created in the dashboard under API keys, shown once. A sending access key is enough for this guide and cannot read contacts or delete domains if it leaks.

A verified domain.

Add your domain, publish the SPF, DKIM and DMARC records Notix gives you, and the from address on it becomes valid. The quickstart walks through it.

Go 1.21 or later.

For context, net/http and errors.As as used here. Nothing to install: every sample on this page is the standard library.

The free plan covers 5,000 emails a month and 200 a day, and the free plan does not ask for a card.

Steps

From an empty main to a queued message.

  1. Keep the key in the environment, and give the send a deadline.

    Export NOTIX_API_KEY and read it with os.Getenv; it never belongs in source. Wrap every send in context.WithTimeout, and in an HTTP server pass the request’s own context so a caller that hangs up cancels the send.

    main.go
    package main
    
    import (
        "context"
        "log"
        "time"
    )
    
    func main() {
        // NOTIX_API_KEY comes from the environment, never from source.
        // Give every send a deadline so a slow network cannot hold a handler open.
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
    
        id, err := send(ctx) // send.go, next step
        if err != nil {
            log.Fatal(err)
        }
        log.Printf("queued %s", id)
    }
    
  2. Send one email.

    One POST to /api/v1/emails with a Bearer key and a JSON body: from, to (an address or a list), subject, html and/or text (send both; some clients only show one), and optional headers, which are forwarded as they are. Notix manages only X-Notix-Email-ID and References. The Idempotency-Key header makes the call safe to retry.

    send.go
    package main
    
    import (
        "bytes"
        "context"
        "encoding/json"
        "fmt"
        "net/http"
        "os"
        "time"
    )
    
    type apiError struct {
        Error struct {
            Code    string `json:"code"`
            Message string `json:"message"`
        } `json:"error"`
    }
    
    func send(ctx context.Context) (string, error) {
        body, _ := json.Marshal(map[string]any{
            "from":    "receipts@acme.com",
            "to":      "customer@example.com",
            "subject": "Your receipt for order 4471",
            "html":    "<p>Thanks for your order.</p>",
            "text":    "Thanks for your order.",
        })
    
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://app.usenotix.dev/api/v1/emails", bytes.NewReader(body))
        if err != nil {
            return "", err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("NOTIX_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        // One key per business event, so a retry can never send twice.
        req.Header.Set("Idempotency-Key", "order-4471-receipt")
    
        res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
        if err != nil {
            return "", err
        }
        defer res.Body.Close()
    
        if res.StatusCode >= 400 {
            var apiErr apiError
            _ = json.NewDecoder(res.Body).Decode(&apiErr)
            return "", fmt.Errorf("%s: %s", apiErr.Error.Code, apiErr.Error.Message)
        }
    
        var out struct {
            EmailID string `json:"emailId"`
        }
        if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
            return "", err
        }
        return out.EmailID, nil
    }
    
  3. Read the answer.

    An error from client.Do means the request never got an answer: a DNS failure, a TLS problem, a timeout, a cancelled context. A status of 400 or above means the API answered and refused; the error envelope’s message is the human sentence and its code tells you which kind of refusal (BAD_REQUEST for an unverified from domain, FORBIDDEN for a sending-access key on the wrong endpoint, RATE_LIMITED for the per-second limit or the plan’s send limit). On 200 the body’s emailId is the id of the queued message. Store it next to the order or the user: it is what you read the message back with and what every webhook event for that message carries as data.id.

  4. Or send through the SMTP relay.

    Already have a mailer built on net/smtp? Point it at the SMTP relay: host smtp.usenotix.dev, port 465 with implicit TLS (or 587 for STARTTLS), username notix, and your API key as the password. On 465 you open the TLS connection yourself and hand it to smtp.NewClient; on 587 you dial plain, call StartTLS, then Auth. The relay posts the parsed message to the same endpoint the API call above uses, so suppression, tracking and webhooks apply either way.

    smtp.go
    package main
    
    import (
        "crypto/tls"
        "net/smtp"
        "os"
    )
    
    func sendViaRelay() error {
        host := "smtp.usenotix.dev"
        // The SMTP password is a Notix API key; the username is always "notix".
        auth := smtp.PlainAuth("", "notix", os.Getenv("NOTIX_API_KEY"), host)
    
        // Port 465 is implicit TLS: open the TLS connection first, then speak SMTP
        // over it. (Port 587 is STARTTLS: dial plain, call c.StartTLS, then Auth.)
        conn, err := tls.Dial("tcp", host+":465", &tls.Config{ServerName: host})
        if err != nil {
            return err
        }
        c, err := smtp.NewClient(conn, host)
        if err != nil {
            return err
        }
        defer c.Quit()
    
        if err := c.Auth(auth); err != nil {
            return err
        }
        if err := c.Mail("receipts@acme.com"); err != nil {
            return err
        }
        if err := c.Rcpt("customer@example.com"); err != nil {
            return err
        }
        w, err := c.Data()
        if err != nil {
            return err
        }
        msg := "From: receipts@acme.com\r\n" +
            "To: customer@example.com\r\n" +
            "Subject: Your receipt for order 4471\r\n" +
            "Content-Type: text/plain; charset=utf-8\r\n\r\n" +
            "Thanks for your order.\r\n"
        if _, err := w.Write([]byte(msg)); err != nil {
            return err
        }
        return w.Close()
    }
    
In production

Deadlines, retries that cannot double-send, and a verified webhook.

Go makes the first two natural. The third is forty lines of the standard library.

Idempotency key.

Set Idempotency-Key on the request and keep it the same for every attempt at the same logical send: the order id, the reset token, the invoice number. The same key and body returns the original emailId with 200 instead of sending again; the same key with a different body answers 409 NOT_UNIQUE; a key still in flight also answers 409, so wait and retry. Keys are up to 256 characters and expire after 24 hours.

Retry with backoff, but only the right errors.

Retry transport errors, 429 and 5xx with exponential backoff and honour the context’s deadline between attempts. Never retry a 400, 403 or 404: they describe your request, not the network, and the answer will be the same next time. With an idempotency key on every attempt, a retry after a timeout that actually succeeded costs nothing.

Contexts and timeouts.

Go’s zero-value http.Client never times out, so set Timeout on the client you send with. Wrap each send in context.WithTimeout so a slow network cannot hold a request handler open, and pass the request’s own context from an HTTP server so a client that hangs up cancels the send.

Verify the webhook.

Delivery, bounces, complaints, opens and clicks arrive as email.* events on a webhook you register in the dashboard. Each request is signed with HMAC-SHA256 over timestamp.rawBody, sent as X-Notix-Signature: v1=… with the timestamp in X-Notix-Timestamp. Verify with crypto/hmac and hmac.Equal, answer 2xx within 10 seconds, and do the slow work after responding.

retry.go
// Retry only what is safe to retry: transport errors, 429 and 5xx. A 4xx
// other than 429 is your bug or your data, and will not fix itself.
func sendWithRetry(ctx context.Context) (string, error) {
    var lastErr error
    for attempt := 0; attempt < 4; attempt++ {
        id, err := send(ctx) // the net/http sample above, same Idempotency-Key each time
        if err == nil {
            return id, nil
        }
        lastErr = err

        var apiErr *apiStatusError // whatever your send() returns for HTTP errors
        if errors.As(err, &apiErr) && apiErr.Status != 429 && apiErr.Status < 500 {
            return "", err
        }

        select {
        case <-ctx.Done():
            return "", ctx.Err()
        case <-time.After(time.Duration(1<<attempt) * 500 * time.Millisecond):
        }
    }
    return "", lastErr
}
webhook.go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

// Notix signs every delivery as HMAC-SHA256(secret, "<timestamp>.<rawBody>")
// and sends it as X-Notix-Signature: v1=<hex>, with the Unix timestamp in
// milliseconds in X-Notix-Timestamp.
func webhook(w http.ResponseWriter, r *http.Request) {
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "unreadable body", http.StatusBadRequest)
        return
    }

    mac := hmac.New(sha256.New, []byte(os.Getenv("NOTIX_WEBHOOK_SECRET")))
    mac.Write([]byte(r.Header.Get("X-Notix-Timestamp") + "." + string(rawBody)))
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Notix-Signature"))) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    switch r.Header.Get("X-Notix-Event") {
    case "email.delivered", "email.bounced", "email.complained":
        // Parse rawBody; data.id is the emailId you were given at send time.
    }

    // Answer 2xx within 10 seconds; do the slow work after responding.
    w.WriteHeader(http.StatusOK)
}

Keep NOTIX_API_KEY and NOTIX_WEBHOOK_SECRET in the environment, give the service that only sends a sending-access key, and keep a full-access key for the jobs that manage domains and contacts. Sending one-time codes? The OTP use case uses the verification API instead of a template of your own, and the deliverability check can run on any message before it goes out. The same shape in another language is in the Node.js guide.

Using SMTP instead

When the mailer is already wired, keep it.

If your service already sends through net/smtp, a mail library or a queue worker, the relay is the shortest path: change the host and credentials and nothing else. You give up the idempotency key and the emailId in the response, which only the API exposes, but every message still lands in the same log, honours the same suppression list and fires the same webhooks. The net/smtp step above is the complete configuration; the same four values work in any SMTP client.

FAQ

Questions, answered.

Is there a Notix package for Go?
Not today. Notix publishes SDKs for TypeScript, Python and PHP. From Go the integration is the JSON API over net/http, which is what this page uses: one POST to /api/v1/emails with a Bearer key, the same Idempotency-Key header and the same error envelope the SDKs use. A typed client is one function and two structs, with no dependency beyond the standard library.
How do I tell a network failure from an API refusal?
Two different things can go wrong. An error from client.Do is a transport failure: the request never got an answer (DNS, TLS, a timeout, a cancelled context), and it is safe to retry with the same idempotency key. A status of 400 or above is the API's own refusal, with a code and message in the error envelope. When the status is 200, the body's emailId is the id of the queued message.
How do I make a retry safe?
Send an Idempotency-Key header and keep it the same on every attempt for that logical send. The same key with the same body returns the original emailId with 200 instead of sending again; the same key with a different body answers 409 NOT_UNIQUE; a key still being processed also answers 409, so wait briefly and retry. Keys are up to 256 characters and expire after 24 hours.
Can I keep using net/smtp?
Yes. Point it at smtp.usenotix.dev on port 465 (implicit TLS) or 587 (STARTTLS) with the username notix and an API key as the password. The relay turns the SMTP session into the same tracked, suppressed send the API makes. You lose the Idempotency-Key header and the emailId in the response, which are API features, but suppression, logs and webhooks apply either way.
Does the free plan cover a Go service in production?
For a small one, yes: 5,000 emails a month and 200 a day, with no card and no end date. Pro raises the allowance to 50,000 units a month for $15; past that, sending stops unless you turn on pay-as-you-go ($1 per 1,000 marketing emails).

Send the first one now.

Verify a domain, copy an API key, run the program. The free plan does not ask for a card.