Java SDK
Spring Boot Integration
Production-ready Spring Boot 3.x integration with auto-configuration, validation, async processing, and webhook handling.
This guide uses the Sent Java SDK (dm.sent:sent-dm-java) with Spring Boot 3.x, Jakarta EE, and modern Java features.
Project Setup
Maven Dependencies
<dependencies>
<dependency>
<groupId>dm.sent</groupId>
<artifactId>sent-dm-java</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Configuration
Application Properties
# application.yml
spring:
application:
name: sent-dm-integration
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/sent_dm}
username: ${DATABASE_USERNAME:postgres}
password: ${DATABASE_PASSWORD:password}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
flyway:
enabled: true
locations: classpath:db/migration
sent:
api:
key: ${SENT_DM_API_KEY}
base-url: ${SENT_DM_BASE_URL:https://api.sent.dm}
timeout-seconds: 30
webhook:
secret: ${SENT_DM_WEBHOOK_SECRET}
path: /webhooks/sent
async:
core-pool-size: 5
max-pool-size: 10
queue-capacity: 100
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
---
# Profile: dev
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:sent_dm_dev
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
---
# Profile: prod
spring:
config:
activate:
on-profile: prod
jpa:
show-sql: false
logging:
level:
com.example.sent: WARNDTOs
// dto/SendMessageRequest.java
package com.example.sent.dto;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Map;
public record SendMessageRequest(
@NotEmpty @Size(max = 100)
List<@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String> to,
@NotBlank @Size(max = 255) String templateId,
@NotBlank @Pattern(regexp = "^[a-zA-Z0-9_-]+$") String templateName,
Map<String, String> parameters,
List<@Pattern(regexp = "^(whatsapp|sms)$") String> channels
) {}
// dto/SendWelcomeRequest.java
package com.example.sent.dto;
import jakarta.validation.constraints.*;
public record SendWelcomeRequest(
@NotBlank @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phoneNumber,
@Size(max = 100) String name,
@Pattern(regexp = "^[a-z]{2}$") String language
) {}
// dto/MessageResponse.java
package com.example.sent.dto;
import java.time.Instant;
import java.util.List;
public record MessageResponse(
String messageId,
String status,
Instant timestamp,
List<String> channels
) {}
// dto/webhook/WebhookEvent.java
package com.example.sent.dto.webhook;
import java.time.Instant;
public record WebhookEvent(String type, String id, Instant timestamp, WebhookEventData data) {}
public record WebhookEventData(String id, String status, WebhookError error) {}
public record WebhookError(String code, String message) {}Entity Layer
// entity/WebhookEventEntity.java
package com.example.sent.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity
@Table(name = "webhook_events", indexes = {
@Index(name = "idx_event_id", columnList = "eventId"),
@Index(name = "idx_status", columnList = "status")
})
public class WebhookEventEntity {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(nullable = false, unique = true)
private String eventId;
@Column(nullable = false)
private String eventType;
private String messageId;
private String status;
@Column(nullable = false, columnDefinition = "TEXT")
private String payload;
@Enumerated(EnumType.STRING)
private ProcessingStatus processingStatus = ProcessingStatus.PENDING;
private Integer retryCount = 0;
@CreationTimestamp
private Instant createdAt;
public enum ProcessingStatus { PENDING, PROCESSING, COMPLETED, FAILED }
public WebhookEventEntity() {}
public WebhookEventEntity(String eventId, String eventType, String payload) {
this.eventId = eventId;
this.eventType = eventType;
this.payload = payload;
}
// Getters and setters...
}
// repository/WebhookEventRepository.java
package com.example.sent.repository;
import com.example.sent.entity.WebhookEventEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface WebhookEventRepository extends JpaRepository<WebhookEventEntity, String> {
Optional<WebhookEventEntity> findByEventId(String eventId);
boolean existsByEventId(String eventId);
}Configuration Classes
// config/AsyncConfig.java
package com.example.sent.config;
import org.springframework.context.annotation.*;
import org.springframework.scheduling.annotation.*;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {
@Bean(name = "webhookTaskExecutor")
public Executor webhookTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("webhook-");
executor.initialize();
return executor;
}
}
// config/SentConfig.java
package com.example.sent.config;
import dm.sent.client.SentDmClient;
import dm.sent.client.okhttp.SentDmOkHttpClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import java.time.Duration;
@Configuration
public class SentConfig {
@Bean
public SentDmClient sentClient(
@Value("${sent.api.key}") String apiKey,
@Value("${sent.api.base-url}") String baseUrl) {
return SentDmOkHttpClient.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.timeout(Duration.ofSeconds(30))
.build();
}
}Service Layer
// service/MessageService.java
package com.example.sent.service;
import com.example.sent.dto.*;
import java.util.concurrent.CompletableFuture;
public interface MessageService {
MessageResponse sendMessage(SendMessageRequest request);
MessageResponse sendWelcomeMessage(SendWelcomeRequest request);
CompletableFuture<MessageResponse> sendMessageAsync(SendMessageRequest request);
}
// service/impl/MessageServiceImpl.java
package com.example.sent.service.impl;
import com.example.sent.dto.*;
import com.example.sent.service.MessageService;
import dm.sent.client.SentDmClient;
import dm.sent.core.JsonValue;
import dm.sent.models.messages.MessageSendParams;
import dm.sent.models.messages.MessageSendResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Service
public class MessageServiceImpl implements MessageService {
private static final Logger logger = LoggerFactory.getLogger(MessageServiceImpl.class);
private final SentDmClient sentClient;
public MessageServiceImpl(SentDmClient sentClient) {
this.sentClient = sentClient;
}
@Override
public MessageResponse sendMessage(SendMessageRequest request) {
logger.info("Sending message to {} recipients", request.to().size());
MessageSendParams params = buildMessageParams(request);
MessageSendResponse response = sentClient.messages().send(params);
return new MessageResponse(response.id(), response.status(), Instant.now(),
request.channels() != null ? request.channels() : List.of("whatsapp", "sms"));
}
@Override
public MessageResponse sendWelcomeMessage(SendWelcomeRequest request) {
String name = request.name() != null ? request.name() : "Customer";
MessageSendParams params = MessageSendParams.builder()
.addTo(request.phoneNumber())
.addChannel("whatsapp")
.template(MessageSendParams.Template.builder()
.name("welcome")
.parameters(MessageSendParams.Template.Parameters.builder()
.putAdditionalProperty("name", JsonValue.from(name))
.build())
.build())
.build();
MessageSendResponse response = sentClient.messages().send(params);
return new MessageResponse(response.id(), response.status(), Instant.now(), List.of("whatsapp"));
}
@Override
@Async("webhookTaskExecutor")
public CompletableFuture<MessageResponse> sendMessageAsync(SendMessageRequest request) {
try {
return CompletableFuture.completedFuture(sendMessage(request));
} catch (Exception e) {
CompletableFuture<MessageResponse> future = new CompletableFuture<>();
future.completeExceptionally(e);
return future;
}
}
private MessageSendParams buildMessageParams(SendMessageRequest request) {
var paramsBuilder = MessageSendParams.Template.Parameters.builder();
if (request.parameters() != null) {
request.parameters().forEach((k, v) ->
paramsBuilder.putAdditionalProperty(k, JsonValue.from(v)));
}
var builder = MessageSendParams.builder()
.to(request.to())
.template(MessageSendParams.Template.builder()
.id(request.templateId())
.name(request.templateName())
.parameters(paramsBuilder.build())
.build());
if (request.channels() != null) request.channels().forEach(builder::addChannel);
return builder.build();
}
}
// service/WebhookService.java
package com.example.sent.service;
import com.example.sent.dto.webhook.WebhookEvent;
import java.util.concurrent.CompletableFuture;
public interface WebhookService {
CompletableFuture<Void> processWebhookAsync(String payload, String signature);
}
// service/impl/WebhookServiceImpl.java
package com.example.sent.service.impl;
import com.example.sent.dto.webhook.WebhookEvent;
import com.example.sent.entity.WebhookEventEntity;
import com.example.sent.repository.WebhookEventRepository;
import com.example.sent.service.WebhookService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
@Service
public class WebhookServiceImpl implements WebhookService {
private static final Logger logger = LoggerFactory.getLogger(WebhookServiceImpl.class);
private final WebhookEventRepository repository;
private final ObjectMapper mapper;
private final String webhookSecret;
public WebhookServiceImpl(WebhookEventRepository repository, ObjectMapper mapper,
@Value("${sent.webhook.secret:}") String webhookSecret) {
this.repository = repository;
this.mapper = mapper;
this.webhookSecret = webhookSecret;
}
@Override
@Async("webhookTaskExecutor")
public CompletableFuture<Void> processWebhookAsync(String payload, String signature) {
try {
if (!verifySignature(payload, signature)) {
throw new SecurityException("Invalid webhook signature");
}
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
if (repository.existsByEventId(event.id())) {
return CompletableFuture.completedFuture(null);
}
WebhookEventEntity entity = new WebhookEventEntity(event.id(), event.type(), payload);
entity.setSignature(signature);
if (event.data() != null) {
entity.setMessageId(event.data().id());
entity.setStatus(event.data().status());
}
repository.save(entity);
processEvent(event, entity);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
logger.error("Failed to process webhook", e);
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(e);
return future;
}
}
private void processEvent(WebhookEvent event, WebhookEventEntity entity) {
entity.setProcessingStatus(WebhookEventEntity.ProcessingStatus.PROCESSING);
repository.save(entity);
logger.info("Processing event: {} - type: {}", event.id(), event.type());
// Handle specific event types here...
entity.setProcessingStatus(WebhookEventEntity.ProcessingStatus.COMPLETED);
entity.setCreatedAt(Instant.now()); // Use as processedAt
repository.save(entity);
}
private boolean verifySignature(String payload, String signature) {
if (webhookSecret == null || webhookSecret.isBlank()) return true;
if (signature == null) return false;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String computed = Base64.getEncoder().encodeToString(hash);
return signature.equals(computed) || signature.equals("sha256=" + computed);
} catch (Exception e) {
return false;
}
}
}REST Controllers
// controller/MessageController.java
package com.example.sent.controller;
import com.example.sent.dto.*;
import com.example.sent.service.MessageService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.CompletableFuture;
@Tag(name = "Messages")
@RestController
@RequestMapping("/api/v1/messages")
public class MessageController {
private final MessageService messageService;
public MessageController(MessageService messageService) {
this.messageService = messageService;
}
@PostMapping("/send")
public ResponseEntity<MessageResponse> sendMessage(@Valid @RequestBody SendMessageRequest request) {
return ResponseEntity.ok(messageService.sendMessage(request));
}
@PostMapping("/welcome")
public ResponseEntity<MessageResponse> sendWelcome(@Valid @RequestBody SendWelcomeRequest request) {
return ResponseEntity.ok(messageService.sendWelcomeMessage(request));
}
@PostMapping("/send/async")
public CompletableFuture<ResponseEntity<MessageResponse>> sendAsync(@Valid @RequestBody SendMessageRequest request) {
return messageService.sendMessageAsync(request).thenApply(ResponseEntity::ok);
}
}
// controller/WebhookController.java
package com.example.sent.controller;
import com.example.sent.service.WebhookService;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Tag(name = "Webhooks")
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private final WebhookService webhookService;
public WebhookController(WebhookService webhookService) {
this.webhookService = webhookService;
}
@PostMapping("/sent")
public CompletableFuture<ResponseEntity<Map<String, Object>>> handleWebhook(
@RequestBody String payload,
@RequestHeader(value = "X-Webhook-Signature", required = false) String signature) {
return webhookService.processWebhookAsync(payload, signature)
.thenApply(v -> ResponseEntity.ok(Map.of("received", true)))
.exceptionally(ex -> ResponseEntity.status(500).body(Map.of("error", ex.getMessage())));
}
}Exception Handling
// exception/GlobalExceptionHandler.java
package com.example.sent.exception;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.*;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final String ERROR_PREFIX = "https://api.example.com/errors/";
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setType(URI.create(ERROR_PREFIX + "validation-error"));
problem.setTitle("Validation Failed");
problem.setInstance(URI.create(req.getRequestURI()));
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(e -> {
String field = e instanceof FieldError ? ((FieldError) e).getField() : e.getObjectName();
errors.put(field, e.getDefaultMessage());
});
problem.setProperty("errors", errors);
return ResponseEntity.badRequest().body(problem);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ProblemDetail> handleConstraint(ConstraintViolationException ex, HttpServletRequest req) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setType(URI.create(ERROR_PREFIX + "constraint-violation"));
problem.setTitle("Constraint Violation");
problem.setInstance(URI.create(req.getRequestURI()));
return ResponseEntity.badRequest().body(problem);
}
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ProblemDetail> handleSecurity(SecurityException ex, HttpServletRequest req) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
problem.setType(URI.create(ERROR_PREFIX + "security-error"));
problem.setTitle("Security Error");
problem.setInstance(URI.create(req.getRequestURI()));
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problem);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleGeneric(Exception ex, HttpServletRequest req) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setType(URI.create(ERROR_PREFIX + "internal-error"));
problem.setTitle("Internal Server Error");
problem.setInstance(URI.create(req.getRequestURI()));
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problem);
}
}Testing
// controller/MessageControllerTest.java
package com.example.sent.controller;
import com.example.sent.dto.*;
import com.example.sent.service.MessageService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(MessageController.class)
class MessageControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper mapper;
@MockitoBean private MessageService messageService;
@Test
void sendWelcome_ShouldReturn200() throws Exception {
when(messageService.sendWelcomeMessage(any()))
.thenReturn(new MessageResponse("msg_123", "pending", Instant.now(), List.of("whatsapp")));
SendWelcomeRequest req = new SendWelcomeRequest("+1234567890", "John", "en");
mockMvc.perform(post("/api/v1/messages/welcome")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.messageId").value("msg_123"));
}
@Test
void sendWelcome_WithInvalidPhone_ShouldReturn400() throws Exception {
SendWelcomeRequest req = new SendWelcomeRequest("invalid", "John", null);
mockMvc.perform(post("/api/v1/messages/welcome")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
// Additional tests...
}
// service/impl/MessageServiceImplTest.java
package com.example.sent.service.impl;
import com.example.sent.dto.*;
import dm.sent.client.SentDmClient;
import dm.sent.models.messages.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MessageServiceImplTest {
@Mock private SentDmClient sentClient;
@Mock private SentDmClient.MessageClient messageClient;
@InjectMocks private MessageServiceImpl messageService;
@Test
void sendMessage_ShouldReturnResponse() {
when(sentClient.messages()).thenReturn(messageClient);
when(messageClient.send(any())).thenReturn(
MessageSendResponse.builder().id("msg_123").status("pending").build());
SendMessageRequest req = new SendMessageRequest(
List.of("+1234567890"), "tpl-id", "welcome", null, List.of("whatsapp"));
MessageResponse resp = messageService.sendMessage(req);
assertThat(resp.messageId()).isEqualTo("msg_123");
}
// Additional tests...
}Docker Compose
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=dev
- SENT_DM_API_KEY=${SENT_DM_API_KEY}
- SENT_DM_WEBHOOK_SECRET=${SENT_DM_WEBHOOK_SECRET}
- DATABASE_URL=jdbc:postgresql://postgres:5432/sent_dm
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: sent_dm
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S sent && adduser -S sent -G sent
COPY --from=builder /app/target/*.jar app.jar
RUN chown -R sent:sent /app
USER sent
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]Environment Variables
# .env
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret
SENT_DM_BASE_URL=https://api.sent.dm
DATABASE_URL=jdbc:postgresql://localhost:5432/sent_dm
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=password
SPRING_PROFILES_ACTIVE=devProject Structure
src/
├── main/java/com/example/sent/
│ ├── SentDmApplication.java
│ ├── config/
│ │ ├── AsyncConfig.java
│ │ └── SentConfig.java
│ ├── controller/
│ │ ├── MessageController.java
│ │ └── WebhookController.java
│ ├── dto/
│ │ ├── SendMessageRequest.java
│ │ ├── SendWelcomeRequest.java
│ │ ├── MessageResponse.java
│ │ └── webhook/WebhookEvent.java
│ ├── entity/WebhookEventEntity.java
│ ├── exception/GlobalExceptionHandler.java
│ ├── repository/WebhookEventRepository.java
│ └── service/
│ ├── MessageService.java
│ ├── WebhookService.java
│ └── impl/
│ ├── MessageServiceImpl.java
│ └── WebhookServiceImpl.java
├── main/resources/application.yml
└── test/java/com/example/sent/
└── controller/MessageControllerTest.javaNext Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the Java SDK reference for advanced features