Notix
Guides

Send email from Java with one HTTP call.

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.

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.

Java 17 or later.

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.

Steps

From application.yml to a queued message.

  1. Configure the key.

    Three values under notix, each a placeholder for an environment variable, so nothing secret is committed. @Value injects them into the client.

    application.yml
    # 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
    
  2. Write the client and send one email.

    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.

    NotixClient.java
    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);
        }
    }
    
  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 JDK has everything the webhook needs: Mac, HexFormat and a constant-time compare.

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.

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.

Timeouts.

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.

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. Bind the body as a String, hash it as sent, compare with MessageDigest.isEqual, and answer 2xx within 10 seconds.

ReceiptService.java
// 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.
NotixWebhookController.java
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.

Using SMTP instead

When the mailer is already wired, keep it.

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.

FAQ

Questions, answered.

Is there a Notix SDK for Java?
No. Notix publishes SDKs for TypeScript, Python, PHP and Go; for Java the integration is the JSON API over java.net.http.HttpClient, which ships with the JDK and 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 class and a few records.
Where should the API key live in a Spring Boot app?
In an environment variable read through application.yml with a ${NOTIX_API_KEY} placeholder, or in your secret manager through Spring Cloud Config or Vault. Never in a properties file committed to source control. Give the service that only sends a sending-access key; keep a full-access key for the jobs that manage domains and contacts.
Can I use RestTemplate, WebClient or OkHttp instead?
Yes; the request is the same three headers and one JSON body whatever sends it. HttpClient is shown because it needs no dependency and handles HTTP/2 and timeouts on its own. With WebClient use bodyValue for the record and retrieve().toEntity(String.class) so a refusal still gives you the error envelope instead of an exception with no body.
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 keep using JavaMail or Spring's JavaMailSender?
Yes, through the SMTP relay: set spring.mail.host to smtp.usenotix.dev, port 587 with STARTTLS (or 465 with SSL), username notix, and an API key as the password. Nothing else in your mail code changes. 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 Java 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 service. The free plan does not ask for a card.