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 package for Java, and you do not need one: the email API is one POST with a bearer token. By the end of this guide a Spring Boot service will send a real email through the JDK’s HttpClient, read the key from configuration, retry without ever double-sending, and 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.
For records, sealed interfaces and pattern matching in switch as used here, plus Spring Boot 3 and Jackson, which Spring Web already brings in. HttpClient is in the JDK.
The free plan covers 5,000 emails a month and 200 a day, and the free plan does not ask for a card.
Three values under notix, each a placeholder for an environment variable, so nothing secret is committed. @Value injects them into the client.
# application.yml
notix:
# Read from the environment, never committed. NOTIX_API_KEY and
# NOTIX_WEBHOOK_SECRET are the names most deployments already use.
api-key: ${NOTIX_API_KEY}
webhook-secret: ${NOTIX_WEBHOOK_SECRET}
# Pointing at a different Notix base URL (a staging environment, say)?
# Override here; the default is the hosted API.
base-url: https://app.usenotix.dev/api/v1
One @Component owns an HttpClient with a connect timeout, builds the POST with the three headers, and turns the answer into a sealed Result: Ok with the emailId, or Refused with the status and the API’s error. The controller sends a receipt and maps the result to a response with a switch that the compiler checks for completeness. Send both html and text; some clients only show one. The third tab is what goes over the wire, so you can check the client against curl.
package com.acme.mail;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
@Component
public class NotixClient {
// Records are the payload; Jackson writes them as camelCase JSON, which
// is what the API reads. Nulls are dropped so optional fields stay out.
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SendEmail(String from, String to, String subject,
String html, String text,
String templateId, Map<String, String> variables) {}
public record Sent(String emailId) {}
public record ApiError(String code, String message) {}
record ErrorEnvelope(ApiError error) {}
public sealed interface Result permits Ok, Refused {}
public record Ok(Sent sent) implements Result {}
public record Refused(int status, ApiError error) implements Result {}
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper json = new ObjectMapper();
private final String apiKey;
private final String baseUrl;
public NotixClient(@Value("${notix.api-key}") String apiKey,
@Value("${notix.base-url}") String baseUrl) {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
public Result send(SendEmail email, String idempotencyKey) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/emails"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
// One key per business event, so a retry can never send twice.
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(email)))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 == 2) {
return new Ok(json.readValue(response.body(), Sent.class));
}
// Every refusal is { "error": { "code", "message" } }; a 413 for a
// body over 20 MB is the one answer with no JSON.
ApiError error = response.body().isBlank()
? new ApiError("HTTP_" + response.statusCode(), "No error body")
: json.readValue(response.body(), ErrorEnvelope.class).error();
return new Refused(response.statusCode(), error);
}
}
package com.acme.mail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class ReceiptController {
private final NotixClient notix;
public ReceiptController(NotixClient notix) {
this.notix = notix;
}
@PostMapping("/orders/{orderId}/receipt")
public ResponseEntity<?> sendReceipt(@PathVariable String orderId) throws Exception {
var email = new NotixClient.SendEmail(
"receipts@acme.com",
"customer@example.com",
"Your receipt for order " + orderId,
"<p>Thanks for your order.</p>",
"Thanks for your order.",
null, null);
return switch (notix.send(email, "order-" + orderId + "-receipt")) {
case NotixClient.Ok ok ->
ResponseEntity.ok(Map.of("emailId", ok.sent().emailId()));
case NotixClient.Refused refused ->
ResponseEntity.status(502).body(refused.error());
};
}
}
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 JDK has everything the webhook needs: Mac, HexFormat and a constant-time compare.
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.
An IOException, a timeout, 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. Spring Retry or Resilience4j do the same declaratively; the key must stay the same across attempts either way.
Set a connect timeout on the client and a request timeout on every send, as the sample does, so a slow network fails your handler rather than holding a servlet thread open. Send from a queue worker when the caller does not need the emailId in its response.
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. Bind the body as a String, hash it as sent, compare with MessageDigest.isEqual, 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.
public NotixClient.Result sendWithRetry(NotixClient.SendEmail email, String key)
throws InterruptedException {
NotixClient.Result last = null;
for (int attempt = 0; attempt < 4; attempt++) {
try {
last = notix.send(email, key);
if (last instanceof NotixClient.Ok) return last;
int status = ((NotixClient.Refused) last).status();
if (status != 429 && status < 500) return last;
} catch (java.io.IOException e) {
// DNS, TLS, connection reset, timeout: retry
} catch (Exception e) {
throw new RuntimeException(e);
}
Thread.sleep(500L * (1L << attempt));
}
return last;
}
// Spring Retry (@Retryable) or Resilience4j do the same declaratively if
// your project already uses one; keep the key stable across attempts.
package com.acme.mail;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
// 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. Bind the body as a String so it is
// verified exactly as sent; a re-serialised object no longer matches.
@RestController
public class NotixWebhookController {
private final byte[] secret;
public NotixWebhookController(@Value("${notix.webhook-secret}") String secret) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
@PostMapping(value = "/webhooks/notix", consumes = "application/json")
public ResponseEntity<Void> receive(@RequestBody String rawBody,
@RequestHeader("X-Notix-Signature") String signature,
@RequestHeader("X-Notix-Timestamp") String timestamp,
@RequestHeader("X-Notix-Event") String event) throws Exception {
// Five minutes of tolerance keeps a captured request from being replayed later.
long sentAt = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - sentAt) > 5 * 60 * 1000L) {
return ResponseEntity.status(401).build();
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] digest = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
String expected = "v1=" + HexFormat.of().formatHex(digest);
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
return ResponseEntity.status(401).build();
}
switch (event) {
case "email.delivered", "email.bounced", "email.complained" -> {
// Parse rawBody; data.id is the emailId you were given at send time.
}
default -> {}
}
// Answer 2xx within 10 seconds; hand the slow work to a queue.
return ResponseEntity.ok().build();
}
}
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 JavaMailSender or Jakarta Mail, the SMTP relay is the shortest path: spring.mail.host set to smtp.usenotix.dev, port 587 with STARTTLS (or 465 with SSL), 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, run the service. The free plan does not ask for a card.