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 the notix-go package, made the same request 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. The package is one command:

terminal
go get github.com/notix/notix-go

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. Create the client, with a context.

    notix.NewClient takes the API key and returns the client and an error. It reads NOTIX_API_KEY from the environment if you prefer not to pass it. Two options matter: WithBaseURL for a different Notix base URL (the root domain only; the package adds /api/v1) and WithHTTPClient to replace the default client and its 30 second timeout. Every call takes a context.Context, so give it a deadline.

    main.go
    package main
    
    import (
        "context"
        "log"
        "os"
        "time"
    
        "github.com/notix/notix-go"
    )
    
    func main() {
        // The key comes from the environment, never from source. NewClient also
        // reads NOTIX_API_KEY on its own when you pass an empty string.
        client, err := notix.NewClient(os.Getenv("NOTIX_API_KEY"))
        if err != nil {
            log.Fatal(err)
        }
    
        // Pointing at a different Notix base URL (a staging environment, say)?
        // notix.WithBaseURL("https://notix.example.com") - the root
        // only; the package adds /api/v1 itself. The default HTTP client has a
        // 30 second timeout; notix.WithHTTPClient swaps it for your own.
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
    
        _ = ctx
        _ = client
    }
    
  2. Send one email.

    client.Emails.Send takes the context and a notix.SendEmailPayload: To (a slice), From, 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 net/http tab is the same call without the package: one POST to /api/v1/emails with a Bearer token, and it is where the Idempotency-Key header goes.

    send.go
    resp, errResp, err := client.Emails.Send(
        ctx,
        notix.SendEmailPayload{
            To:      []string{"customer@example.com"},
            From:    "receipts@acme.com",
            Subject: "Your receipt for order 4471",
            HTML:    "<p>Thanks for your order.</p>",
            Text:    "Thanks for your order.",
            Headers: map[string]string{"X-Campaign": "receipts"},
        },
    )
    if err != nil {
        log.Fatal(err) // the request never got an answer: DNS, TLS, timeout
    }
    if errResp != nil {
        log.Fatalf("API error: %s", errResp.Message) // the API said no
    }
    
    log.Printf("queued %s", resp.EmailID)
    
  3. Read the three return values.

    err means the request never got an answer: a DNS failure, a TLS problem, a timeout, a cancelled context. errResp means the API answered and refused; its Message is the human sentence from the error envelope, and the envelope’s 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). When both are nil, resp.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 package calls, 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. The package documents Send without a per-request header option, so this lives on the net/http path today.

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.

The package’s default HTTP client times out after 30 seconds. 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.

Do I need the notix-go package, or can I use net/http?
Either. The package is a thin typed wrapper over the same JSON endpoints, so the net/http sample on this page sends exactly what client.Emails.Send sends. Use the package for typed payloads and the three-value return that separates a transport failure from an API refusal; use net/http when you want no dependency, or when you need to set a header the package does not expose yet, such as Idempotency-Key.
Why does Send return three values?
Because two different things can go wrong. The last value, err, is a transport error: the request never got an answer (DNS, TLS, a timeout, a cancelled context). The middle value, errResp, is the API's own refusal, with the code and message from the error envelope. Check err first, then errResp; when both are nil, resp.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 a month for $15 and meters anything above that on the same invoice.

Send the first one now.

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