Sending messages from Spring Boot with the Sent Java SDK
This guide shows you how to wire Sent messaging into an existing Spring Boot app: install the Java SDK, register a client bean, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode.
Prerequisites
This guide assumes a working Spring Boot 3.x app and familiarity with beans and controllers. You also need:
- A Sent API key from the API Keys page in your Sent Dashboard
- A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the webhook local development guide
Install the SDK
Add the SDK dependency to your build:
<!-- pom.xml -->
<dependency>
<groupId>dm.sent</groupId>
<artifactId>sent-java</artifactId>
<version>0.30.0</version>
</dependency>For Gradle, use implementation("dm.sent:sent-java:0.30.0") instead.
Configure the client bean
Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4:
export SENT_DM_API_KEY="your-api-key"
export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret"Register one shared client bean; fromEnv() reads SENT_DM_API_KEY (or the sent.dmApiKey system property):
// config/SentConfig.java
package com.example.sent.config;
import dm.sent.client.SentClient;
import dm.sent.client.okhttp.SentOkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SentConfig {
@Bean
public SentClient sentClient() {
return SentOkHttpClient.fromEnv();
}
}Send a template message from a controller
Define a validated request record; the pass-through sandbox flag lets callers exercise the endpoint without delivering anything:
// dto/SendMessageRequest.java
package com.example.sent.dto;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Map;
public record SendMessageRequest(
@NotEmpty List<@Pattern(regexp = "^\\+[1-9]\\d{1,14}$") String> to, // E.164 numbers
@NotBlank String templateName, // reference by name or id, never both
Map<String, String> parameters,
List<@Pattern(regexp = "^(whatsapp|sms|rcs)$") String> channels,
boolean sandbox // true = validate and simulate only
) {}Add the controller that builds the params and calls messages().send:
// controller/MessageController.java
package com.example.sent.controller;
import com.example.sent.dto.SendMessageRequest;
import dm.sent.client.SentClient;
import dm.sent.core.JsonValue;
import dm.sent.models.messages.MessageSendParams;
import dm.sent.models.messages.MessageSendResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/messages")
public class MessageController {
private final SentClient sentClient;
public MessageController(SentClient sentClient) {
this.sentClient = sentClient;
}
@PostMapping("/send")
public ResponseEntity<Map<String, String>> sendMessage(@Valid @RequestBody SendMessageRequest request) {
var parameters = MessageSendParams.Template.Parameters.builder();
if (request.parameters() != null) {
request.parameters().forEach((k, v) -> parameters.putAdditionalProperty(k, JsonValue.from(v)));
}
var builder = MessageSendParams.builder()
.to(request.to())
.template(MessageSendParams.Template.builder()
.name(request.templateName())
.parameters(parameters.build())
.build())
.sandbox(request.sandbox());
if (request.channels() != null) {
request.channels().forEach(builder::addChannel); // omit to let Sent pick per recipient
}
MessageSendResponse response = sentClient.messages().send(builder.build());
MessageSendResponse.Data data = response.data().orElseThrow();
var recipient = data.recipients().orElseThrow().get(0);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of(
"message_id", recipient.messageId().orElse(""),
"status", data.status().orElse("")
));
}
}Sent accepts sends asynchronously: the API responds with status QUEUED and one message_id per recipient-and-channel pair. Store the message_id. Delivery outcomes arrive on your webhook endpoint instead of in this response.
Receive delivery webhooks
Define a record for the event envelope. Every event carries field, event, timestamp, and payload, and template events omit event:
// dto/WebhookEvent.java
package com.example.sent.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
@JsonIgnoreProperties(ignoreUnknown = true)
public record WebhookEvent(String field, String event, Instant timestamp, Payload payload) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Payload(
@JsonProperty("message_id") String messageId,
@JsonProperty("message_status") String messageStatus,
@JsonProperty("inbound_number") String inboundNumber,
String channel,
String text
) {}
}Add a controller that verifies the X-Webhook-Signature header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}, keyed with the base64-decoded secret after stripping its whsec_ prefix. Refer to webhook signature verification for the full scheme:
// controller/WebhookController.java
package com.example.sent.controller;
import com.example.sent.dto.WebhookEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
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.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private static final Logger logger = LoggerFactory.getLogger(WebhookController.class);
private final ObjectMapper mapper;
private final String webhookSecret;
public WebhookController(ObjectMapper mapper,
@Value("${SENT_DM_WEBHOOK_SECRET:}") String webhookSecret) {
this.mapper = mapper;
this.webhookSecret = webhookSecret;
}
@PostMapping("/sent")
public ResponseEntity<Map<String, Object>> handleWebhook(
@RequestBody String payload,
@RequestHeader(value = "X-Webhook-ID", required = false) String webhookId,
@RequestHeader(value = "X-Webhook-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Webhook-Signature", required = false) String signature) throws Exception {
if (!verifySignature(payload, webhookId, timestamp, signature)) {
return ResponseEntity.status(401).body(Map.of("error", "invalid signature"));
}
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
if ("message".equals(event.field())) {
var p = event.payload();
switch (event.event() != null ? event.event() : "") {
case "message.delivered" -> logger.info("Message {} delivered", p.messageId());
case "message.failed" -> logger.error("Message {} failed (status {})",
p.messageId(), p.messageStatus());
case "message.received" -> logger.info("Inbound {} from {}: {}",
p.channel(), p.inboundNumber(), p.text());
default -> logger.info("Message {} status: {}", p.messageId(), p.messageStatus());
}
}
return ResponseEntity.ok(Map.of("received", true));
}
private boolean verifySignature(String payload, String webhookId, String timestamp, String signature) {
// Fail closed: never accept webhooks when the secret is not configured
if (webhookSecret == null || webhookSecret.isBlank()) return false;
if (webhookId == null || timestamp == null || signature == null) return false;
try {
// Strip the "whsec_" prefix and base64-decode to get the raw HMAC key
String keyBase64 = webhookSecret.startsWith("whsec_") ? webhookSecret.substring(6) : webhookSecret;
byte[] keyBytes = Base64.getDecoder().decode(keyBase64);
// Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}"
String signed = webhookId + "." + timestamp + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
String expected = "v1," + Base64.getEncoder().encodeToString(
mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
return false;
}
}
}Keep this endpoint outside Spring Security's authenticated routes, because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the webhooks getting started guide; otherwise register over the API:
curl -X POST https://api.sent.dm/v3/webhooks \
-H "x-api-key: $SENT_DM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Spring Boot integration",
"endpoint_url": "https://your-domain.example/webhooks/sent",
"event_types": ["message"]
}'Copy two values from the response: the webhook id (used to test delivery in the next step) and signing_secret. Put the secret in SENT_DM_WEBHOOK_SECRET. The message event type covers every message event; the webhook event types reference lists all payload fields.
Verify the integration
Start the app with your credentials loaded:
./mvnw spring-boot:runSend a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed:
curl -X POST http://localhost:8080/api/messages/send \
-H "Content-Type: application/json" \
-d '{"to": ["+14155551234"], "templateName": "welcome",
"parameters": {"name": "Ada"}, "sandbox": true}'The response should contain a message_id and "status": "QUEUED". A 400 here means the request shape is wrong: sandbox requests return real validation errors.
Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook id you copied:
curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \
-H "x-api-key: $SENT_DM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "message.delivered"}'Your application log should show a Message ... delivered line, and the endpoint should have answered 200 {"received": true}. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix.
Adapt this to your app
- If webhook processing does slow work (database writes, downstream calls), verify the signature synchronously, return 200, and hand the event to an
@Asyncexecutor so retries do not pile up. The appendix below has the executor; see also handling webhook retries. - If you process each event exactly once, derive an idempotency key from the event sub-type plus
message_idand skip duplicates. The appendix shows the pattern. - To send to many recipients, pass them all in
to. Sent creates one message per recipient-and-channel pair in a single call. - To send free-form text instead of a template, use
.text(...)instead of.template(...), since each send carries exactly one of the two.
Appendix: production scaffolding
The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Spring Boot stack. Adapt them to your own conventions rather than adopting them wholesale.
Next steps
- Review the webhook event types reference for every payload field
- Work through the webhook production checklist before going live
- Explore the Java SDK reference for retries, timeouts, and error types
- Read the SDK best practices guide for production deployments