Java SDK

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:

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

Send 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 @Async executor 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_id and 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

On this page