> ## Documentation Index
> Fetch the complete documentation index at: https://nuntly.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Send email from Spring Boot

> Send transactional email from a Spring Boot app on Java 21 with the Nuntly starter, then receive delivery events through a signed webhook.

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](/docs/guides/account-setup).
* A verified sending domain. See [sending domains](/docs/guides/sending-domains).
* An API key with the **Sending only** permission, exported as an environment variable. See [API keys](/docs/guides/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`.

```bash theme={null}
export NUNTLY_API_KEY=ntly_your_api_key_here
```

## Install and configure the Spring Boot starter

<Steps>
  <Step title="Create a Spring Boot project">
    Generate a project with Spring Web and Java 21 from [Spring Initializr](https://start.spring.io), or from the command line:

    ```bash theme={null}
    curl https://start.spring.io/starter.tgz \
      -d type=maven-project -d dependencies=web -d javaVersion=21 \
      -d groupId=com.example -d artifactId=demo | tar -xzf -
    ```
  </Step>

  <Step title="Add the starter">
    The starter pulls the core SDK, `com.nuntly:nuntly-java`, as a transitive dependency. Check [Maven Central](https://central.sonatype.com/artifact/com.nuntly/nuntly-java-spring) for the latest version.

    <CodeGroup>
      ```xml Maven (pom.xml) theme={null}
      <dependency>
        <groupId>com.nuntly</groupId>
        <artifactId>nuntly-java-spring</artifactId>
        <version>1.2.2</version>
      </dependency>
      ```

      ```kotlin Gradle (build.gradle.kts) theme={null}
      dependencies {
          implementation("com.nuntly:nuntly-java-spring:1.2.2")
      }
      ```

      ```groovy Gradle (build.gradle) theme={null}
      dependencies {
          implementation 'com.nuntly:nuntly-java-spring:1.2.2'
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Set the API key">
    Add one line to `src/main/resources/application.properties`. Spring resolves the placeholder from the environment at startup.

    ```properties src/main/resources/application.properties theme={null}
    nuntly.api-key=${NUNTLY_API_KEY}
    ```
  </Step>
</Steps>

The starter binds every property under the `nuntly` prefix. All of them except the API key are optional:

| Property | Default | Description |
| - | - | - |
| `nuntly.api-key` | `NUNTLY_API_KEY` env var | API key sent as a bearer token. |
| `nuntly.timeout` | `60s` | HTTP timeout per attempt. |
| `nuntly.max-retries` | `2` | Number of retries after the first attempt. |
| `nuntly.retry-disabled` | `false` | Turns automatic retries off. |
| `nuntly.debug` | `false` | Prints the method, URL, status, and request ID to stderr. |

<Note>
  If your app already sends with `JavaMailSender` and you want to keep it, point `spring-boot-starter-mail` at the [Nuntly SMTP server](/docs/guides/send-emails-with-smtp) 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.
</Note>

## 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`:

```java WelcomeEmailService.java theme={null}
package com.example.demo;

import com.nuntly.sdk.Nuntly;
import com.nuntly.sdk.models.CreateEmailRequest;
import com.nuntly.sdk.models.CreateEmailResponse;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class WelcomeEmailService {

  private final Nuntly nuntly;

  public WelcomeEmailService(Nuntly nuntly) {
    this.nuntly = nuntly;
  }

  public CreateEmailResponse sendWelcome(String recipient) {
    return nuntly.emails().send(CreateEmailRequest.builder()
        .from("Acme <hello@yourdomain.com>")
        .to(List.of(recipient))
        .subject("Welcome to Acme")
        .html("<p>Your account is ready. Sign in to get started.</p>")
        .text("Your account is ready. Sign in to get started.")
        .build());
  }
}
```

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`:

```java WelcomeController.java theme={null}
package com.example.demo;

import java.util.Map;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class WelcomeController {

  record WelcomeRequest(String email) {}

  private final WelcomeEmailService welcomeEmailService;

  public WelcomeController(WelcomeEmailService welcomeEmailService) {
    this.welcomeEmailService = welcomeEmailService;
  }

  @PostMapping("/welcome")
  public Map<String, String> welcome(@RequestBody WelcomeRequest request) {
    var email = welcomeEmailService.sendWelcome(request.email());
    return Map.of("id", email.id(), "status", email.status().value());
  }
}
```

Start the app and send the email to an address you can read:

```bash theme={null}
./mvnw spring-boot:run

curl -X POST http://localhost:8080/welcome \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com"}'
```

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](/docs/guides/observability-events) shows the same events for your whole organization.

A webhook can subscribe to these events:

| Event | Description |
| - | - |
| `email.sent` | The email was accepted for delivery. |
| `email.delivered` | The recipient's mail server accepted the email. |
| `email.deliveryDelayed` | Delivery is temporarily delayed. |
| `email.bounced` | The recipient's server rejected the email. |
| `email.complained` | The recipient marked the email as spam. |
| `email.rejected` | Nuntly refused to send the email. |
| `email.failed` | The email could not be delivered after all attempts. |
| `email.opened` | The recipient opened the email. Requires open tracking. |
| `email.clicked` | The recipient clicked a link. Requires click tracking. |

You enable open and click tracking per domain. See [enable open and click tracking](/docs/guides/sending-domains#enable-open-and-click-tracking).

### Receive webhooks in a Spring Boot controller

<Steps>
  <Step title="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](/docs/guides/webhooks) for every option.
  </Step>

  <Step title="Store the signing secret">
    Copy the signing secret, which starts with `whsec_`, and export it next to your API key:

    ```bash theme={null}
    export NUNTLY_WEBHOOK_SECRET=whsec_your_signing_secret
    ```

    <Warning>
      The signing secret is displayed only once. If you lose it, rotate it from the webhook settings and update your environment.
    </Warning>
  </Step>

  <Step title="Add the controller">
    Create `src/main/java/com/example/demo/NuntlyWebhookController.java`:

    ```java NuntlyWebhookController.java theme={null}
    package com.example.demo;

    import com.nuntly.sdk.lib.Webhook;
    import com.nuntly.sdk.lib.WebhookVerificationError;
    import com.nuntly.sdk.models.EmailBouncedEvent;
    import com.nuntly.sdk.models.EmailComplainedEvent;
    import com.nuntly.sdk.models.EmailDeliveredEvent;
    import com.nuntly.sdk.models.WebhookEvent;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    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;

    @RestController
    public class NuntlyWebhookController {

      private static final Logger log = LoggerFactory.getLogger(NuntlyWebhookController.class);

      private final String signingSecret;

      public NuntlyWebhookController(@Value("${NUNTLY_WEBHOOK_SECRET}") String signingSecret) {
        this.signingSecret = signingSecret;
      }

      @PostMapping("/webhooks/nuntly")
      public ResponseEntity<Void> receive(
          @RequestBody String payload,
          @RequestHeader(name = "webhook-signature", required = false) String signature) {
        WebhookEvent event;
        try {
          event = Webhook.verify(payload, signature, signingSecret);
        } catch (WebhookVerificationError e) {
          return ResponseEntity.status(401).build();
        }

        switch (event) {
          case EmailDeliveredEvent e -> log.info("Email {} delivered", e.data().id());
          case EmailBouncedEvent e ->
              log.warn("Email {} bounced: {}", e.data().id(), e.data().bounce().bounceType());
          case EmailComplainedEvent e -> log.warn("Email {} marked as spam", e.data().id());
          default -> log.info("Event {} ignored", event.type());
        }
        return ResponseEntity.ok().build();
      }
    }
    ```
  </Step>
</Steps>

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](/docs/guides/observability-webhooks) tab.

The [handle webhook events](/docs/guides/integrate-webhooks) guide describes the signature scheme in detail, and [sending events](/docs/guides/sending-webhooks) 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:

```java theme={null}
import com.nuntly.sdk.RequestOptions;

nuntly.emails().send(
    request,
    RequestOptions.builder().idempotencyKey("welcome-" + userId).build());
```

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](/docs/guides/idempotency) 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:

```properties src/main/resources/application.properties theme={null}
nuntly.timeout=10s
nuntly.max-retries=2
```

### 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:

```java theme={null}
import com.nuntly.sdk.ApiError;
import com.nuntly.sdk.ConnectionError;

try {
  welcomeEmailService.sendWelcome(user.email());
} catch (ApiError e) {
  switch (e) {
    case ApiError.AuthenticationError ae -> log.error("Invalid Nuntly API key");
    case ApiError.RateLimitError rl ->
        log.warn("Rate limited, retry in {} ms", rl.retryAfter().orElse(0L));
    default -> log.error("Nuntly returned HTTP {} (request {})",
        e.status(), e.requestId().orElse("unknown"));
  }
} catch (ConnectionError e) {
  log.error("Nuntly unreachable after retries", e);
}
```

`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

<CardGroup cols={2}>
  <Card title="Handle webhook events" icon="webhook" href="/docs/guides/integrate-webhooks">
    Signature verification details and webhook best practices
  </Card>

  <Card title="Sending events" icon="paper-plane" href="/docs/guides/sending-webhooks">
    Payload reference for every sending event
  </Card>

  <Card title="Idempotency keys" icon="repeat" href="/docs/guides/idempotency">
    Key formats, strategies, and error codes
  </Card>

  <Card title="SDK reference" icon="code" href="/docs/api-reference/sdks">
    Install and configure the Nuntly SDKs
  </Card>
</CardGroup>

## Other frameworks

<CardGroup cols={3}>
  <Card title="Send email from Express" icon="node-js" href="/docs/guides/send-email-from-express">
    Send from an Express route and verify webhooks on the raw body
  </Card>

  <Card title="Send email from NestJS" icon="node-js" href="/docs/guides/send-email-from-nestjs">
    Inject the client as a provider and send from a service
  </Card>

  <Card title="Send email from Next.js" icon="react" href="/docs/guides/send-email-from-nextjs">
    Send from a Server Action and receive webhooks in a Route Handler
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.