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.
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.
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.
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.
For context, net/http and errors.As as used here. The package is one command:
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.
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.
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
}
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.
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)
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
}
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.
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.
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()
}
Go makes the first two natural. The third is forty lines of the standard library.
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 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.
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.
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 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
}
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.
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.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesThe SDK, plain fetch, or Nodemailer pointed at the relay: three ways to send from Node in a few minutes.
Use casesSend one-time codes by email or SMS with two API calls, with expiry, length and risk scoring handled for you.
LearnWhat a pre-send deliverability check looks at, the verdict and score it returns, and the four findings that block a send.
ProductPoint any framework's mailer at one host and port; the relay turns SMTP into the same tracked, suppressed send.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Verify a domain, copy an API key, run the program. The free plan does not ask for a card.