Send Email with Java using the Rovela API
Learn how to send transactional email from Java using HttpClient, OkHttp, and Spring Boot RestClient. Includes retry logic with idempotency keys, batch sending with Semaphore, and webhook signature verification for Spring Boot and Jakarta Servlet.

This guide covers sending transactional email from Java using the Rovela API. It includes plain HttpClient (Java 11+), OkHttp, Spring Boot with RestClient, retry logic, batch sending, and webhook verification with Spring Boot and Jakarta Servlet.
Prerequisites
Set your API key as an environment variable:
export ROVELA_API_KEY="re_..."
Send with Java HttpClient (Java 11+)
No extra dependencies — just the standard library.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class RovelaExample {
private static final String API_URL = "https://api.rovela.dev/emails";
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("ROVELA_API_KEY");
String body = """
{
"from": "Acme <noreply@yourdomain.com>",
"to": "user@example.com",
"subject": "Welcome aboard",
"html": "<p>Thanks for signing up!</p>"
}
""";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
Send with OkHttp
OkHttp is a widely-used HTTP client for JVM applications.
import okhttp3.*;
import java.io.IOException;
public class RovelaOkHttp {
private static final MediaType JSON = MediaType.get("application/json");
private static final String API_URL = "https://api.rovela.dev/emails";
public static void main(String[] args) throws IOException {
String apiKey = System.getenv("ROVELA_API_KEY");
OkHttpClient client = new OkHttpClient();
String bodyJson = """
{
"from": "Acme <noreply@yourdomain.com>",
"to": "user@example.com",
"subject": "Welcome aboard",
"html": "<p>Thanks for signing up!</p>"
}
""";
Request request = new Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.post(RequestBody.create(bodyJson, JSON))
.build();
try (Response response = client.newCall(request).execute()) {
System.out.println(response.code());
System.out.println(response.body().string());
}
}
}
Send with Spring Boot RestClient
RestClient is the modern synchronous HTTP client in Spring 6 / Spring Boot 3.2+.
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.http.MediaType;
import java.util.Map;
@Service
public class EmailService {
private final RestClient restClient;
private final String apiKey = System.getenv("ROVELA_API_KEY");
public EmailService() {
this.restClient = RestClient.builder()
.baseUrl("https://api.rovela.dev")
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
public String sendEmail(String to, String subject, String html) {
Map<String, String> payload = Map.of(
"from", "Acme <noreply@yourdomain.com>",
"to", to,
"subject", subject,
"html", html
);
return restClient.post()
.uri("/emails")
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(String.class);
}
}
Retry with idempotency key
Generate the key once before the loop and reuse it on every attempt. This prevents duplicate delivery when the network drops after the server accepts the request.
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
public class RetryExample {
private static final String API_URL = "https://api.rovela.dev/emails";
private static final int MAX_RETRIES = 4;
public static HttpResponse<String> sendWithRetry(String payload) throws Exception {
String idempotencyKey = UUID.randomUUID().toString(); // generated once, reused
String apiKey = System.getenv("ROVELA_API_KEY");
HttpClient client = HttpClient.newHttpClient();
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 429) {
return response;
}
long delay = (long) (1000 * Math.pow(2, attempt));
String retryAfter = response.headers().firstValue("Retry-After").orElse(null);
if (retryAfter != null) delay = Long.parseLong(retryAfter) * 1000L;
Thread.sleep(delay);
}
throw new RuntimeException("Rate limit exceeded after " + MAX_RETRIES + " attempts");
}
}
Batch sending
Use a Semaphore to cap concurrency while sending to many recipients in parallel.
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;
public class BatchSender {
private static final String API_URL = "https://api.rovela.dev/emails";
private static final int CONCURRENCY = 5;
record Recipient(String email, String subject, String html) {}
public static void sendBatch(List<Recipient> recipients) throws Exception {
String apiKey = System.getenv("ROVELA_API_KEY");
HttpClient client = HttpClient.newHttpClient();
Semaphore semaphore = new Semaphore(CONCURRENCY);
List<CompletableFuture<Void>> futures = new java.util.ArrayList<>();
for (Recipient r : recipients) {
semaphore.acquire();
CompletableFuture<Void> f = CompletableFuture.runAsync(() -> {
try {
String body = String.format(
"""{"from":"Acme <noreply@yourdomain.com>","to":"%s","subject":"%s","html":"%s"}""",
r.email(), r.subject(), r.html()
);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", UUID.randomUUID().toString())
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp =
client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(r.email() + ": " + resp.statusCode());
} catch (Exception e) {
System.err.println(r.email() + " failed: " + e.getMessage());
} finally {
semaphore.release();
}
});
futures.add(f);
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
}
Verify webhooks with Spring Boot
Rovela signs every delivery event with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. Compute HMAC-SHA256 over <timestamp>.<raw-body> using your webhook secret, then compare with a constant-time function.
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
public class WebhookController {
private final String webhookSecret = System.getenv("ROVELA_WEBHOOK_SECRET");
@PostMapping("/webhooks/rovela")
public ResponseEntity<String> handle(HttpServletRequest request) throws Exception {
byte[] rawBody = request.getInputStream().readAllBytes();
String sigHeader = request.getHeader("x-resend-signature");
if (sigHeader == null) return ResponseEntity.badRequest().body("missing signature");
Map<String, String> parts = java.util.Arrays.stream(sigHeader.split(","))
.map(s -> s.split("=", 2))
.filter(a -> a.length == 2)
.collect(Collectors.toMap(a -> a[0], a -> a[1]));
String timestamp = parts.get("t");
String v1 = parts.get("v1");
if (timestamp == null || v1 == null)
return ResponseEntity.badRequest().body("invalid signature format");
String signedString = timestamp + "." + new String(rawBody, StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(signedString.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(expected.getBytes(), v1.getBytes()))
return ResponseEntity.badRequest().body("invalid signature");
// parse and handle event
// ObjectMapper mapper = new ObjectMapper();
// Map<?,?> payload = mapper.readValue(rawBody, Map.class);
return ResponseEntity.ok("ok");
}
}
MessageDigest.isEqual performs a constant-time byte comparison, preventing timing attacks.
Spring Boot buffers the request body by default. To receive the raw bytes correctly without any body-parsing middleware interfering, annotate the controller method parameter with @RequestBody byte[] rawBody instead of reading from the input stream if you prefer, or keep the HttpServletRequest approach above.
Verify webhooks with plain Jakarta Servlet
If you are not using Spring, here is a plain servlet:
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.Collectors;
@WebServlet("/webhooks/rovela")
public class RovelaWebhookServlet extends HttpServlet {
private final String webhookSecret = System.getenv("ROVELA_WEBHOOK_SECRET");
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws IOException, ServletException {
byte[] rawBody = req.getInputStream().readAllBytes();
String sigHeader = req.getHeader("x-resend-signature");
if (sigHeader == null) { res.sendError(400, "missing signature"); return; }
Map<String, String> parts = java.util.Arrays.stream(sigHeader.split(","))
.map(s -> s.split("=", 2))
.filter(a -> a.length == 2)
.collect(java.util.stream.Collectors.toMap(a -> a[0], a -> a[1]));
String timestamp = parts.get("t");
String v1 = parts.get("v1");
if (timestamp == null || v1 == null) { res.sendError(400, "invalid format"); return; }
try {
String signedString = timestamp + "." + new String(rawBody, StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(signedString.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(expected.getBytes(), v1.getBytes())) {
res.sendError(400, "invalid signature");
return;
}
res.setStatus(200);
res.getWriter().write("ok");
} catch (Exception e) {
res.sendError(500, e.getMessage());
}
}
}