Notix
Guides

Send email from Rust with one HTTP call.

There is no Notix crate, and you do not need one: the email API is one POST with a bearer token. By the end of this guide a tokio program will send a real email through reqwest with serde types, read the key from the environment, retry without ever double-sending, and an axum route will verify the webhook that tells it what happened.

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.

A recent stable toolchain.

Rust 1.75 or later for the let else and async patterns used here, and these crates:

Cargo.toml
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
axum = "0.7"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"

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. Write the client.

    One module owns a reqwest::Client with a 10 second timeout, the key and the base URL. SendEmail derives Serialize with rename_all = "camelCase" so template_id goes out as templateId, and skip_serializing_if keeps unset optionals out of the body. The answer becomes Result<Sent, SendError>, where the error enum separates a transport failure from the API’s refusal.

  2. Send one email.

    main reads NOTIX_API_KEY, builds the payload with both html and text (some clients only show one), and matches on the result. The third tab is what goes over the wire, so you can check the client against curl.

    src/notix.rs
    use serde::{Deserialize, Serialize};
    use std::time::Duration;
    
    // serde writes the struct as camelCase JSON, which is what the API reads.
    // Optional fields are skipped when None so the request validates cleanly.
    #[derive(Serialize)]
    #[serde(rename_all = "camelCase")]
    pub struct SendEmail<'a> {
        pub from: &'a str,
        pub to: &'a str,
        pub subject: &'a str,
        #[serde(skip_serializing_if = "Option::is_none")]
        pub html: Option<&'a str>,
        #[serde(skip_serializing_if = "Option::is_none")]
        pub text: Option<&'a str>,
        #[serde(skip_serializing_if = "Option::is_none")]
        pub template_id: Option<&'a str>,
    }
    
    #[derive(Deserialize, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct Sent {
        pub email_id: String,
    }
    
    #[derive(Deserialize, Debug)]
    pub struct ApiError {
        pub code: String,
        pub message: String,
    }
    
    #[derive(Deserialize)]
    struct ErrorEnvelope {
        error: ApiError,
    }
    
    #[derive(Debug)]
    pub enum SendError {
        // The request never got an answer: DNS, TLS, timeout.
        Transport(reqwest::Error),
        // The API answered and refused.
        Refused { status: u16, error: ApiError },
    }
    
    pub struct Notix {
        http: reqwest::Client,
        api_key: String,
        base_url: String,
    }
    
    impl Notix {
        pub fn new(api_key: String) -> Self {
            Self {
                http: reqwest::Client::builder()
                    .timeout(Duration::from_secs(10))
                    .build()
                    .expect("reqwest client"),
                api_key,
                // Pointing at a different Notix base URL (a staging environment, say)?
                // Change this; the default is the hosted API.
                base_url: "https://app.usenotix.dev/api/v1".to_string(),
            }
        }
    
        pub async fn send(&self, email: &SendEmail<'_>, idempotency_key: &str) -> Result<Sent, SendError> {
            let response = self
                .http
                .post(format!("{}/emails", self.base_url))
                .bearer_auth(&self.api_key)
                // One key per business event, so a retry can never send twice.
                .header("Idempotency-Key", idempotency_key)
                .json(email)
                .send()
                .await
                .map_err(SendError::Transport)?;
    
            let status = response.status();
            if status.is_success() {
                return response.json::<Sent>().await.map_err(SendError::Transport);
            }
    
            // Every refusal is { "error": { "code", "message" } }; a 413 for a
            // body over 20 MB is the one answer with no JSON.
            let error = match response.json::<ErrorEnvelope>().await {
                Ok(envelope) => envelope.error,
                Err(_) => ApiError { code: format!("HTTP_{}", status.as_u16()), message: "No error body".into() },
            };
            Err(SendError::Refused { status: status.as_u16(), error })
        }
    }
    
  3. Read the answer.

    A 2xx carries emailId: store it next to the order or the user, because it is what every webhook event for that message carries as data.id. Anything else is { error: { code, message } }: BAD_REQUEST for a from address on an unverified domain, UNAUTHORIZED for a bad key, NOT_UNIQUE for an idempotency key reused with a different body, RATE_LIMITED with a Retry-After header, RISK_REFUSED when a one-time code request is refused, and INSUFFICIENT_BALANCE for an SMS with an empty wallet. A body over 20 MB is refused at the edge with 413 and no JSON, which is why the client falls back to a synthetic error.

In production

Retries that cannot double-send, and a verified webhook.

The type system does the routing; hmac and sha2 do the verifying.

Idempotency key.

Keep the same Idempotency-Key on every attempt at the same logical send. A replay with the same body returns the original emailId; a different body answers 409; keys are up to 256 characters and expire after 24 hours. The idempotency page has the full semantics.

Retry the right errors.

A Transport error, 429 and 5xx are worth a second try with backoff. A 400, 401, 403 or 409 describes your request and will answer the same way next time; the match guard in the sample returns those immediately.

Timeouts and tasks.

The client’s 10 second timeout bounds every send. Inside a request handler, spawn the send with tokio::spawn or push it to a channel when the caller does not need the emailId in its response, so a slow network never holds the handler open.

Verify the webhook.

Delivery, bounces, complaints, opens and clicks arrive as email.* events on an endpoint 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. Take the body as Bytes, feed the raw bytes to the MAC, verify with verify_slice, and answer 2xx within 10 seconds.

src/notix.rs
// Retry only what is safe to retry: a transport failure, 429 and 5xx.
// A 400, 401, 403 or 409 describes your request and will not change.
// The same Idempotency-Key on every attempt makes the retry harmless.
pub async fn send_with_retry(
    client: &Notix,
    email: &SendEmail<'_>,
    key: &str,
) -> Result<Sent, SendError> {
    let mut last = None;
    for attempt in 0..4u32 {
        match client.send(email, key).await {
            Ok(sent) => return Ok(sent),
            Err(SendError::Refused { status, error }) if status != 429 && status < 500 => {
                return Err(SendError::Refused { status, error });
            }
            Err(e) => last = Some(e),
        }
        tokio::time::sleep(Duration::from_millis(500 * (1 << attempt))).await;
    }
    Err(last.expect("at least one attempt"))
}
src/webhook.rs
use axum::{body::Bytes, http::{HeaderMap, StatusCode}, routing::post, Router};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;

// 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. Take the body as Bytes so it is verified
// exactly as sent; a re-serialised value no longer matches.
async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("NOTIX_WEBHOOK_SECRET").expect("NOTIX_WEBHOOK_SECRET");
    let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or("");
    let timestamp = header("x-notix-timestamp");
    let signature = header("x-notix-signature");

    // Five minutes of tolerance keeps a captured request from being replayed later.
    let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as i64;
    match timestamp.parse::<i64>() {
        Ok(sent_at) if (now_ms - sent_at).abs() <= 5 * 60 * 1000 => {}
        _ => return StatusCode::UNAUTHORIZED,
    }

    // The signature is "v1=" + lowercase hex; verify_slice compares in constant time.
    let Some(hex_sig) = signature.strip_prefix("v1=") else { return StatusCode::UNAUTHORIZED };
    let Ok(expected) = hex::decode(hex_sig) else { return StatusCode::UNAUTHORIZED };

    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("hmac key");
    mac.update(timestamp.as_bytes());
    mac.update(b".");
    mac.update(&body);
    if mac.verify_slice(&expected).is_err() {
        return StatusCode::UNAUTHORIZED;
    }

    match header("x-notix-event") {
        "email.delivered" | "email.bounced" | "email.complained" => {
            // serde_json::from_slice(&body); data.id is the emailId you were given at send time.
        }
        _ => {}
    }

    // Answer 2xx within 10 seconds; hand the slow work to a task or a queue.
    StatusCode::OK
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/webhooks/notix", post(webhook));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Keep the key and the 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. The same shape with a shell and nothing else is in the curl guide; the signature check is explained step by step on the webhook signature page.

Using SMTP instead

When the mailer is already wired, keep it.

If your service already sends through lettre, the SMTP relay is the shortest path: host smtp.usenotix.dev, port 587 with STARTTLS (or 465 with implicit TLS), username notix, and an API key as the password. 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.

FAQ

Questions, answered.

Is there a Notix SDK for Rust?
No. Notix publishes SDKs for TypeScript, Python, PHP and Go; for Rust the integration is the JSON API over reqwest, which is what this page uses. The API is small (one POST to send, one to send a batch, one GET to read a message back) and the error envelope is the same everywhere, so a typed client is one module with two structs and an enum.
Why an enum for the error instead of a boxed error?
Because two different things can go wrong and callers should treat them differently. Transport means the request never got an answer: DNS, TLS, a timeout. Refused means the API answered with a status and an error envelope, and the code tells you why: BAD_REQUEST for a from address on an unverified domain, UNAUTHORIZED for a bad key, NOT_UNIQUE for an idempotency key reused with a different body, RATE_LIMITED for the per-second limit. Only the first kind and 429 or 5xx are worth retrying.
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 order id, the reset token, the invoice number. 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 use a blocking client or a different framework?
Yes. reqwest::blocking::Client sends the same request from synchronous code, and the webhook handler is plain HMAC over the raw body, so it ports to actix-web, warp or Rocket by swapping the extractors: whatever gives you the raw bytes and the headers. Do not deserialise the body before verifying it; the signature covers the bytes as sent.
Can I keep using lettre over SMTP?
Yes, through the SMTP relay: SmtpTransport::relay("smtp.usenotix.dev") uses port 587 with STARTTLS by default, with the username notix and an API key as the password; port 465 works with the implicit-TLS builder. You give up the Idempotency-Key header and the emailId in the response, which are API features, but suppression, the message log and webhooks apply either way.
Does the free plan cover a Rust 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, cargo run. The free plan does not ask for a card.