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.
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.
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.
Rust 1.75 or later for the let else and async patterns used here, and these crates:
# 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.
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.
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.
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 })
}
}
mod notix;
use notix::{Notix, SendEmail, SendError};
#[tokio::main]
async fn main() {
// The key comes from the environment, never from source.
let client = Notix::new(std::env::var("NOTIX_API_KEY").expect("NOTIX_API_KEY"));
let email = SendEmail {
from: "receipts@acme.com",
to: "customer@example.com",
subject: "Your receipt for order 4471",
html: Some("<p>Thanks for your order.</p>"),
text: Some("Thanks for your order."),
template_id: None,
};
match client.send(&email, "order-4471-receipt").await {
Ok(sent) => println!("queued {}", sent.email_id),
Err(SendError::Refused { status, error }) => {
eprintln!("API said no ({status}): {} {}", error.code, error.message)
}
Err(SendError::Transport(e)) => eprintln!("no answer: {e}"),
}
}
curl -X POST https://app.usenotix.dev/api/v1/emails \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Idempotency-Key: order-4471-receipt" \
-H "Content-Type: application/json" \
-d '{ "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." }'
# 200 { "emailId": "eml_abc123" }
# 400 { "error": { "code": "BAD_REQUEST", "message": "..." } } your request
# 401 { "error": { "code": "UNAUTHORIZED", "message": "..." } } bad or missing key
# 409 { "error": { "code": "NOT_UNIQUE", "message": "..." } } same key, different body
# 429 { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Try again in 60 seconds." } }
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.
The type system does the routing; hmac and sha2 do the verifying.
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.
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.
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.
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.
// 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"))
}
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.
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.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesOne POST with curl from a shell script, with the headers, the JSON body, the response and the error codes you will meet.
LearnWhy a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
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, cargo run. The free plan does not ask for a card.