Skip to main content
Send a transactional email from a Spring Boot app on Java 21, then receive its delivery events in a controller through a verified webhook. You use the official Spring Boot starter, com.nuntly:nuntly-java-spring, which registers a Nuntly client bean. The starter requires Spring Boot 3.4 or later. The samples use the SDK’s exceptions directly and catch them with try/catch.

Prerequisites

  • A Nuntly account. The free plan includes 3,000 emails per month (100 per day) and needs no credit card. See account setup.
  • A verified sending domain. See sending domains.
  • An API key with the Sending only permission, exported as an environment variable. See API keys. Keep it out of application.properties and out of Git.
  • Java 21 or later. The SDK uses records, sealed interfaces, and pattern matching for switch.

Install and configure the Spring Boot starter

1

Create a Spring Boot project

Generate a project with Spring Web and Java 21 from Spring Initializr, or from the command line:
2

Add the starter

The starter pulls the core SDK, com.nuntly:nuntly-java, as a transitive dependency. Check Maven Central for the latest version.
3

Set the API key

Add one line to src/main/resources/application.properties. Spring resolves the placeholder from the environment at startup.
src/main/resources/application.properties
The starter binds every property under the nuntly prefix. All of them except the API key are optional:
If your app already sends with JavaMailSender and you want to keep it, point spring-boot-starter-mail at the Nuntly SMTP server instead. The samples use the API through the SDK, which returns the email ID, sends an idempotency key, and raises one exception type per HTTP status.

Send your first email

Put the send in a @Service so your signup code can call it. Create src/main/java/com/example/demo/WelcomeEmailService.java:
WelcomeEmailService.java
Spring injects the Nuntly bean through the constructor. Use a from address on your verified domain. Sending both html and text gives mail clients that block HTML a readable fallback. To try it before wiring it into your signup flow, expose it in src/main/java/com/example/demo/WelcomeController.java:
WelcomeController.java
Start the app and send the email to an address you can read:
The response looks like {"id":"em_01k...","status":"queued"}. The API answers 202 Accepted: queued means Nuntly accepted the email, and delivery events follow. Keep the em_ ID: every later event about this email carries it.

Observe what happened

Email timeline in the dashboard

Open Emails in the dashboard, open the email you sent, and click Email Timeline to see each event with its timestamp. The Events tab of the observability dashboard shows the same events for your whole organization. A webhook can subscribe to these events: You enable open and click tracking per domain. See enable open and click tracking.

Receive webhooks in a Spring Boot controller

1

Create the webhook

In the dashboard, go to Webhooks and click Create Webhook. Enter your public HTTPS URL ending in /webhooks/nuntly and select at least email.delivered, email.bounced, and email.complained. See webhooks for every option.
2

Store the signing secret

Copy the signing secret, which starts with whsec_, and export it next to your API key:
The signing secret is displayed only once. If you lose it, rotate it from the webhook settings and update your environment.
3

Add the controller

Create src/main/java/com/example/demo/NuntlyWebhookController.java:
NuntlyWebhookController.java
How the controller works:
  • The body stays a String. The signature covers the exact bytes Nuntly sent. Parsing the body into an object first and serializing it again can change those bytes and break verification.
  • Webhook.verify runs the full check. It parses the webhook-signature header (t=<unix seconds>,v0=<hex>) and computes an HMAC-SHA256 of <timestamp>.<body> with your secret. It compares the result in constant time and rejects timestamps older than 5 minutes. When the header carries two signatures, for example during a secret rotation, either one passes. On failure it throws WebhookVerificationError, which the controller turns into a 401.
  • The result is a sealed type. WebhookEvent has one record per event type, so a switch gives you typed access to e.data(). Event types added after your SDK version arrive as UnknownWebhookEvent and fall into default.
To test on your machine, expose port 8080 through an HTTPS tunnel and use the tunnel URL as the webhook endpoint. Send another welcome email, and Email em_... delivered appears in your logs once the recipient’s server accepts it. In production:
  • Return 200 first and move slow work, such as database updates, off the request thread.
  • Deduplicate on event.id() (it starts with evt_): Nuntly retries failed deliveries, so an event can arrive more than once.
  • On email.bounced or email.complained, stop sending to that address in your own data.
  • If deliveries keep failing, Nuntly disables the webhook and emails your organization owner. Once your endpoint is fixed, replay missed events from the webhooks observability tab.
The handle webhook events guide describes the signature scheme in detail, and sending events lists the payload of each event.

Production notes

Idempotency keys

When you pass no key, emails().send() generates a UUID v4 and reuses it across its own automatic retries, so a timeout followed by a retry never sends the email twice. Each call gets a new key, so a job that runs twice or a form submitted twice makes two calls and sends two emails. Derive the key from the business event to cover those cases:
Keys are 8 to 255 characters long and expire after 24 hours. Reusing a key with a different body returns 409 idempotency_key_payload_mismatch, raised as ConflictError. See idempotency keys for key strategies.

Retries

By default, the client makes up to 3 attempts per request (the first one plus 2 retries) with a 60-second timeout each. It retries network errors and HTTP 408, 409, 429, 500, 502, 503, and 504, and waits for the Retry-After header on a 429. A send called from a web request blocks that request thread. Set a shorter timeout for that case:
src/main/resources/application.properties

Error handling

Every API error extends ApiError, with one subclass per status: BadRequestError, AuthenticationError, PermissionDeniedError, NotFoundError, ConflictError, UnprocessableEntityError, RateLimitError, and InternalServerError. Network failures that outlast the retries throw ConnectionError. Both extend NuntlyError, a RuntimeException. Keep the signup successful when the welcome email fails. Catch the error, log identifiers, and continue:
e.requestId() reads the X-Request-Id header of the response. Include it when you contact support.

Separate environments

Create one API key per environment with the Sending only permission, and restrict the production key to your production domain. Give each environment its own webhook endpoint, which also gives it its own signing secret. Because both values come from environment variables, the same application.properties works in every environment.

What not to log

Recipient addresses are personal data. Log identifiers instead: the em_ email ID, the evt_ event ID, and the request ID. The controllers above never print e.data().to(), the subject, or the raw payload. When you need the recipient of an email, look it up in the dashboard by its ID. The SDK debug mode follows the same rule: nuntly.debug=true prints the method, URL, status, and request ID, never request or response bodies.

Next steps

Handle webhook events

Signature verification details and webhook best practices

Sending events

Payload reference for every sending event

Idempotency keys

Key formats, strategies, and error codes

SDK reference

Install and configure the Nuntly SDKs

Other frameworks

Send email from Express

Send from an Express route and verify webhooks on the raw body

Send email from NestJS

Inject the client as a provider and send from a service

Send email from Next.js

Send from a Server Action and receive webhooks in a Route Handler