Since 1998 5000+ Clients 99.9% Uptime TRAI Certified
☕ DEVELOPER GUIDE · JAVA

Send SMS from Java & Spring Boot in India

Java code for an Indian DLT-compliant SMS gateway, from a JDK-only HttpClient call to a Spring Boot 3 service. The service uses RestClient, @ConfigurationProperties records and retries that never send the same message twice. It also covers DLT template filling, Unicode counting and OTPs. Every example uses the real AvanceZone API.

📅 Updated 29 Oct 2026⏱️ 15 min read🧪 Java 17+, Spring Boot 3.2+

To send an SMS from Java, POST a JSON body with the sender ID, recipient and message to the gateway using java.net.http.HttpClient, with your API key in an Authorization: Bearer header. In Spring Boot 3.2+ the idiomatic way is a RestClient bean configured from @ConfigurationProperties. In India the text must match a DLT template approved for the sender ID. With AvanceZone the endpoint is /api/v2/sms/send, and a successful call returns a message_id for delivery tracking.

1. Before you write code

  • Java 17 or later. HttpClient needs 11. The examples also use records and text blocks, which need 16 and 15, and 17 is the oldest LTS that has all three. Spring Boot 3 requires 17 anyway.
  • An API key from your SMS gateway account, supplied as the SMS_API_KEY environment variable. Keep it out of application.yml in Git.
  • An approved sender ID. Transactional and service headers are six letters, such as AVNZON. Promotional headers are usually six digits. Since May 2025 handsets show a category suffix, such as VM-AVNZON-T. Your code sends just AVNZON.
  • Approved DLT templates. If you have none yet, read our DLT registration guide first.

2. Send one SMS with HttpClient

No dependencies, one file. Java 11+ can run a single source file directly with java SendSms.java:

JavaSendSms.java
// SendSms.java: run with  SMS_API_KEY=xxxx java SendSms.java   (Java 17+)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class SendSms {
    public static void main(String[] args) throws Exception {
        String json = """
            {
              "sender_id": "AVNZON",
              "recipient": "919876543210",
              "message": "Your OTP for login is 482913. It is valid for 10 minutes. Do not share it with anyone. -AVNZON",
              "route": "transactional"
            }
            """;

        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://whitelabled.securesmsc.com/api/v2/sms/send"))
                .timeout(Duration.ofSeconds(15))
                .header("Authorization", "Bearer " + System.getenv("SMS_API_KEY"))
                .header("Content-Type", "application/json")
                .header("Accept", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}

A text block is fine for a fixed test message. The moment a customer's name or an OTP goes into the body, build the JSON with Jackson instead. String concatenation breaks on the first quote or backslash in the data.

3. Read the response

JSONHTTP 200
{
  "success": true,
  "message": "SMS queued successfully",
  "data": {
    "message_id": 1849302,
    "queue_id": 1849302,
    "status": "queued",
    "estimated_delivery": "2026-10-29T10:42:05.000000Z"
  }
}
OutcomeMeaningRetry the send?
200Accepted and queued, not yet deliveredStore message_id
400 / 401 / 422Invalid sender ID, invalid API key, validation error or TEMPLATE_MISMATCHNo. Fix the request
429, 502, 503Rate limit, or the gateway is briefly unavailableYes, with back-off
500, 504, HttpTimeoutExceptionThe message may already be queuedNot automatically. Check first
ConnectException, HttpConnectTimeoutExceptionNever reached the gatewayYes

4. A plain Java client with safe retries

Outside Spring, a small class with Jackson (com.fasterxml.jackson.core:jackson-databind) is enough. Note which exceptions it catches. Only failures that happen before the request is sent are retried:

JavaSmsClient.java
// SmsClient.java  (Java 17+, plus jackson-databind)
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public final class SmsClient {
    private static final Set<Integer> RETRY_STATUS = Set.of(429, 502, 503);   // never 500/504 on a send
    private static final int MAX_ATTEMPTS = 3;

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    private final ObjectMapper json = new ObjectMapper();
    private final String apiKey;
    private final String baseUrl;

    public SmsClient(String apiKey) {
        this(apiKey, "https://whitelabled.securesmsc.com/api/v2");
    }

    public SmsClient(String apiKey, String baseUrl) {
        this.apiKey = apiKey;
        this.baseUrl = baseUrl;
    }

    /** Sends one SMS and returns the gateway's message_id. */
    public long send(String to, String message, String senderId, Map<String, Object> extra)
            throws IOException, InterruptedException {
        Map<String, Object> payload = new HashMap<>(extra);     // e.g. dlt_template_id, unicode
        payload.put("sender_id", senderId);
        payload.put("recipient", to);
        payload.put("message", message);

        HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/sms/send"))
                .timeout(Duration.ofSeconds(15))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .header("Accept", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload)))
                .build();

        for (int attempt = 1; ; attempt++) {
            HttpResponse<String> response;
            try {
                response = http.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (HttpConnectTimeoutException | ConnectException | UnknownHostException e) {
                if (attempt >= MAX_ATTEMPTS) throw e;            // never connected: safe to retry
                Thread.sleep(500L * attempt);
                continue;
            }
            // Any other IOException (HttpTimeoutException, connection reset) propagates:
            // the request was sent and may have been queued, so it is not retried.

            int status = response.statusCode();
            if (RETRY_STATUS.contains(status) && attempt < MAX_ATTEMPTS) {
                Thread.sleep(500L * attempt);
                continue;
            }
            JsonNode body = parse(response.body());
            if (status == 200 && body.path("success").asBoolean()) {
                return body.path("data").path("message_id").asLong();
            }
            throw new SmsApiException(status, body.path("error_code").asText(null),
                    body.path("error").asText("HTTP " + status) + ": " + body.path("message").asText(""));
        }
    }

    private JsonNode parse(String text) {
        try {
            return json.readTree(text);
        } catch (IOException notJson) {
            return json.createObjectNode();
        }
    }

    public static final class SmsApiException extends RuntimeException {
        private final int status;
        private final String errorCode;

        SmsApiException(int status, String errorCode, String message) {
            super(message);
            this.status = status;
            this.errorCode = errorCode;
        }

        public int status() { return status; }
        public String errorCode() { return errorCode; }               // e.g. "TEMPLATE_MISMATCH"
        public boolean mayHaveBeenSent() { return status == 500 || status == 504; }
    }

    public static void main(String[] args) throws Exception {
        SmsClient sms = new SmsClient(System.getenv("SMS_API_KEY"));
        long id = sms.send("919876543210",
                "Your order #A1023 has been shipped and will reach you by 31 Oct. -AVNZON",
                "AVNZON", Map.of());
        System.out.println("queued as " + id);
    }
}

The two timeouts are different exceptions, and that matters. HttpConnectTimeoutException means the TCP connection never opened, so nothing was sent. Its parent, HttpTimeoutException, from .timeout() on the request, means the request went out and no response came back in time. The gateway may have queued it. That is why the multi-catch names the connect subclass explicitly and lets the parent propagate.

5. Spring Boot: configuration properties

In Spring Boot, keep the settings in one immutable record. Boot 3 binds records through their constructor, with no setters and no @ConstructorBinding:

YAMLapplication.yml
# application.yml
avancezone:
  sms:
    api-key: ${SMS_API_KEY}          # startup fails if the variable is missing
    sender-id: AVNZON
    connect-timeout: 5s
    read-timeout: 15s
JavaSmsProperties.java
// SmsProperties.java
package com.example.sms;

import java.time.Duration;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "avancezone.sms")
public record SmsProperties(
        @DefaultValue("https://whitelabled.securesmsc.com/api/v2") String baseUrl,
        String apiKey,
        @DefaultValue("AVNZON") String senderId,
        @DefaultValue("5s") Duration connectTimeout,
        @DefaultValue("15s") Duration readTimeout) {
}

Build one RestClient for the gateway. Boot 3.2+ auto-configures a RestClient.Builder. Give it a JDK request factory so both timeouts are explicit, whichever HTTP library happens to be on the classpath:

JavaSmsConfig.java
// SmsConfig.java
package com.example.sms;

import java.net.http.HttpClient;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.web.client.RestClient;

@Configuration
@EnableRetry
@EnableConfigurationProperties(SmsProperties.class)
class SmsConfig {

    @Bean
    RestClient smsRestClient(RestClient.Builder builder, SmsProperties props) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(props.connectTimeout())
                .build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
        factory.setReadTimeout(props.readTimeout());

        return builder
                .baseUrl(props.baseUrl())
                .requestFactory(factory)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

Dependencies for Boot 3: spring-boot-starter-web, org.springframework.retry:spring-retry (Boot manages its version) and spring-boot-starter-aop. Spring Retry's annotations are applied through AOP proxies, which is why the last one is needed.

6. A RestClient service with record DTOs

Records map the JSON in both directions. Package-private records can share one file:

JavaSmsDtos.java
// SmsDtos.java
package com.example.sms;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;

@JsonInclude(JsonInclude.Include.NON_NULL)              // omit optional fields when null
record SendSmsRequest(
        @JsonProperty("sender_id") String senderId,
        String recipient,
        String message,
        @JsonProperty("dlt_template_id") String dltTemplateId,
        Boolean unicode) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record SendSmsResponse(boolean success, String message, Data data) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    record Data(@JsonProperty("message_id") long messageId,
                String status,
                @JsonProperty("estimated_delivery") String estimatedDelivery) {
    }
}

@JsonIgnoreProperties(ignoreUnknown = true)
record StatusResponse(boolean success, Data data) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    record Data(@JsonProperty("message_id") long messageId, String status, String recipient) {
    }
}

@JsonIgnoreProperties(ignoreUnknown = true)
record ApiError(boolean success, String error, String message,
                @JsonProperty("error_code") String errorCode) {
}

A sealed exception hierarchy gives callers exactly three cases to handle. That is also what lets Spring Retry target only the safe one:

JavaSmsException.java
// SmsException.java
package com.example.sms;

public abstract sealed class SmsException extends RuntimeException
        permits SmsException.Temporary, SmsException.Uncertain, SmsException.Rejected {

    SmsException(String message, Throwable cause) {
        super(message, cause);
    }

    /** Never reached the gateway, or it answered 429/502/503. Safe to retry. */
    public static final class Temporary extends SmsException {
        public Temporary(String message, Throwable cause) { super(message, cause); }
    }

    /** May already be queued (timeout, 500, 504). Do NOT resend automatically. */
    public static final class Uncertain extends SmsException {
        public Uncertain(String message, Throwable cause) { super(message, cause); }
    }

    /** Refused (400, 401, 422 ...). Retrying cannot help; fix the request. */
    public static final class Rejected extends SmsException {
        private final int status;
        private final String errorCode;

        public Rejected(int status, String errorCode, String message, Throwable cause) {
            super(message, cause);
            this.status = status;
            this.errorCode = errorCode;
        }

        public int status() { return status; }
        public String errorCode() { return errorCode; }        // e.g. "TEMPLATE_MISMATCH"
    }
}
JavaSmsService.java
// SmsService.java
package com.example.sms;

import java.net.ConnectException;
import java.net.UnknownHostException;
import java.net.http.HttpConnectTimeoutException;

import org.springframework.http.MediaType;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

@Service
public class SmsService {

    private final RestClient rest;
    private final SmsProperties props;

    public SmsService(RestClient smsRestClient, SmsProperties props) {
        this.rest = smsRestClient;
        this.props = props;
    }

    /** Sends one SMS and returns its message_id. Retried only when provably not sent. */
    @Retryable(retryFor = SmsException.Temporary.class, maxAttempts = 3,
               backoff = @Backoff(delay = 500, multiplier = 2))
    public long send(String to, String message, String dltTemplateId) {
        Boolean unicode = "UCS-2".equals(SmsParts.count(message).encoding()) ? Boolean.TRUE : null;
        SendSmsRequest request = new SendSmsRequest(props.senderId(), to, message, dltTemplateId, unicode);
        try {
            SendSmsResponse response = rest.post()
                    .uri("/sms/send")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(request)
                    .retrieve()
                    .body(SendSmsResponse.class);
            if (response == null || !response.success() || response.data() == null) {
                throw new SmsException.Rejected(200, null, "Unexpected response from the SMS gateway", null);
            }
            return response.data().messageId();
        } catch (RestClientResponseException e) {
            throw classify(e);
        } catch (ResourceAccessException e) {
            throw neverConnected(e)
                    ? new SmsException.Temporary("Could not connect to the SMS gateway", e)
                    : new SmsException.Uncertain("No response; the SMS may already be queued", e);
        }
    }

    /** A status lookup is a GET and changes nothing, so any I/O error, 429 or 5xx may be retried. */
    @Retryable(retryFor = {ResourceAccessException.class, HttpServerErrorException.class,
                           HttpClientErrorException.TooManyRequests.class},
               maxAttempts = 3, backoff = @Backoff(delay = 500, multiplier = 2))
    public String status(long messageId) {
        StatusResponse response = rest.get()
                .uri("/sms/status/{id}", messageId)
                .retrieve()
                .body(StatusResponse.class);
        return (response == null || response.data() == null) ? "unknown" : response.data().status();
    }

    private static SmsException classify(RestClientResponseException e) {
        int status = e.getStatusCode().value();
        if (status == 429 || status == 502 || status == 503) {
            return new SmsException.Temporary("HTTP " + status, e);
        }
        if (status >= 500) {                                     // 500, 504: may already be queued
            return new SmsException.Uncertain("HTTP " + status + "; check status before resending", e);
        }
        ApiError body = errorBody(e);
        return new SmsException.Rejected(status,
                body == null ? null : body.errorCode(),
                body == null ? e.getStatusText() : body.error() + ": " + body.message(), e);
    }

    private static ApiError errorBody(RestClientResponseException e) {
        try {
            return e.getResponseBodyAs(ApiError.class);
        } catch (RuntimeException notJson) {
            return null;
        }
    }

    private static boolean neverConnected(Throwable e) {
        for (Throwable t = e; t != null; t = t.getCause()) {
            if (t instanceof ConnectException || t instanceof UnknownHostException
                    || t instanceof HttpConnectTimeoutException) {
                return true;
            }
        }
        return false;
    }
}

RestClient wraps every I/O failure in ResourceAccessException, whether the connection was refused or the response timed out. neverConnected() walks the cause chain to tell the two apart. HTTP errors arrive as RestClientResponseException subclasses, and getResponseBodyAs() (Spring 6.0+) turns the JSON error body into the ApiError record.

7. Retries with Spring Retry

Look at the two @Retryable annotations above. They differ on purpose:

  • send() retries only SmsException.Temporary. That covers connect failures plus 429, 502 and 503. A 500 or 504 can come back after the gateway queued the message, so those become Uncertain and are never retried automatically. The same goes for a read timeout. Retrying them is how a customer gets three identical OTPs.
  • status() retries any I/O error and any 5xx. A status lookup changes nothing, so repeating it is harmless.

Three ways @Retryable silently does nothing: @EnableRetry is missing; AOP is not on the classpath; or send() is called from another method of SmsService itself, which bypasses the proxy. Call it from a different bean. On Spring Boot 4 (Spring Framework 7), the framework's own @Retryable in org.springframework.resilience.annotation, enabled with @EnableResilientMethods, can replace Spring Retry. The rule stays the same: includes = SmsException.Temporary.class on the send.

For order alerts and reminders, call send() from an @Async method or a queue consumer (RabbitMQ, Kafka), so a slow gateway never holds a web request thread. For bulk, POST /api/v2/sms/send-bulk takes a recipients list of up to 1,000 numbers. Chunk larger lists with List.subList() and send the chunks one after another.

8. DLT templates in Java

The gateway checks every message against the DLT template approved for its header, and only the variables may differ. Since TRAI's direction of 18 November 2025, each variable must be tagged by type when the template is registered: number, text, URL, app link, call-back number or email. Existing templates got 60 days to be updated, after which messages that fail tag validation are rejected.

  • Tag names depend on the operator portal. Airtel uses {#numeric#}, {#alphanumeric#}, {#url#}, {#urlott#}, {#cbn#} and {#email#}. Vi and BSNL use {#num#}, {#alp#}, {#urg#}, {#uro#}, {#cbn#} and {#eml#}. Our variable tagging guide has the details.
  • Length limits vary by type. Tagged variables generally allow 40 characters, and URLs 120. The legacy {#var#} allows 30. Confirm on your operator's portal.
  • Links and call-back numbers must be whitelisted on DLT. Non-whitelisted URLs have been blocked since 1 October 2024.
JavaDltTemplate.java
// DltTemplate.java
package com.example.sms;

import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class DltTemplate {
    private static final Pattern PLACEHOLDER = Pattern.compile("\\{#(\\w+)#\\}");
    private static final Map<String, Integer> MAX_LEN =
            Map.of("var", 30, "url", 120, "urlott", 120, "urg", 120, "uro", 120);   // others: 40
    private static final Set<String> DIGITS_ONLY = Set.of("numeric", "num", "number");

    private DltTemplate() {
    }

    public static String fill(String template, Object... values) {
        Matcher m = PLACEHOLDER.matcher(template);
        StringBuilder out = new StringBuilder();
        int i = 0;
        while (m.find()) {
            if (i >= values.length) {
                throw new IllegalArgumentException("Not enough values for: " + template);
            }
            String tag = m.group(1);
            String value = String.valueOf(values[i++]);
            int limit = MAX_LEN.getOrDefault(tag, 40);
            if (value.length() > limit) {                       // never truncate: a cut URL is broken
                throw new IllegalArgumentException("{#" + tag + "#} is " + value.length() + " chars; limit " + limit);
            }
            if (DIGITS_ONLY.contains(tag) && !value.matches("\\d+")) {
                throw new IllegalArgumentException("{#" + tag + "#} must be digits only");
            }
            m.appendReplacement(out, Matcher.quoteReplacement(value));
        }
        if (i != values.length) {
            throw new IllegalArgumentException("Too many values for: " + template);
        }
        m.appendTail(out);
        return out.toString();
    }
}

Matcher.quoteReplacement() matters here. Without it, a value containing $ or \, which is common in amounts and addresses, would be read as a group reference and throw. If the gateway still returns TEMPLATE_MISMATCH, our DLT error codes page walks through the usual causes.

9. Unicode, Tamil and segment counting

A single character outside the GSM-7 alphabet switches the whole SMS to UCS-2, and capacity drops from 160 to 70 characters (153 and 67 per part when split). Tamil, Hindi, emoji, the ₹ sign and curly quotes all do it. Java has one advantage here: String.length() counts UTF-16 code units, which is exactly what a UCS-2 SMS is billed on.

JavaSmsParts.java
// SmsParts.java
package com.example.sms;

public final class SmsParts {
    private static final String GSM7_BASIC =
            "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
            + "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
    private static final String GSM7_EXTENDED = "^{}\\[~]|€\f";     // each costs 2 characters

    public record Result(String encoding, int length, int parts) {
    }

    private SmsParts() {
    }

    public static Result count(String text) {
        boolean gsm = text.codePoints()
                .allMatch(cp -> GSM7_BASIC.indexOf(cp) >= 0 || GSM7_EXTENDED.indexOf(cp) >= 0);
        if (gsm) {
            int length = text.codePoints().map(cp -> GSM7_EXTENDED.indexOf(cp) >= 0 ? 2 : 1).sum();
            return new Result("GSM-7", length, length <= 160 ? 1 : (length + 152) / 153);
        }
        int length = text.length();          // UTF-16 code units: the unit UCS-2 SMS counts
        return new Result("UCS-2", length, length <= 70 ? 1 : (length + 66) / 67);
    }

    public static void main(String[] args) {
        System.out.println(count("Your OTP for login is 482913. It is valid for 10 minutes. Do not share it with anyone. -AVNZON"));
        // Result[encoding=GSM-7, length=94, parts=1]
        System.out.println(count("உங்கள் OTP 482913. 10 நிமிடங்கள் செல்லுபடியாகும். -AVNZON"));
        // Result[encoding=UCS-2, length=57, parts=1]
        String emoji = "Order shipped 📦 Track: avancezone.com/t/A1023 -AVNZON";
        System.out.println(emoji.length() + " units, " + emoji.codePointCount(0, emoji.length()) + " code points");
        // 54 units, 53 code points
    }
}

The comments show the expected output. The same strings give the same counts in our Python and Node.js versions, because all three count UTF-16 units for UCS-2. Use length() for billing and codePointCount() only if you need code points. Tamil runs long because vowel signs are separate code points: "செல்லுபடியாகும்".length() is 15.

10. OTP with SecureRandom and HMAC

JavaOtpService.java
// OtpService.java
package com.example.sms;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class OtpService {

    private static final String TEMPLATE =
            "Your OTP for login is {#numeric#}. It is valid for 10 minutes. Do not share it with anyone. -AVNZON";
    private static final Duration TTL = Duration.ofMinutes(10);
    private static final int MAX_GUESSES = 5;

    private record Pending(byte[] digest, Instant expiresAt, int guesses) {
    }

    private final SecureRandom random = new SecureRandom();
    private final Map<String, Pending> pending = new ConcurrentHashMap<>();   // Redis with >1 instance
    private final SmsService sms;
    private final byte[] pepper;

    public OtpService(SmsService sms, @Value("${otp.pepper}") String pepper) {
        this.sms = sms;
        this.pepper = pepper.getBytes(StandardCharsets.UTF_8);
    }

    public void issue(String phone) {
        // Rate-limit per phone and per IP before calling this (for example with Bucket4j).
        String code = String.format("%06d", random.nextInt(1_000_000));
        pending.put(phone, new Pending(hmac(phone, code), Instant.now().plus(TTL), 0));
        sms.send(phone, DltTemplate.fill(TEMPLATE, code), null);
    }

    public boolean verify(String phone, String code) {
        Pending p = pending.computeIfPresent(phone,
                (k, v) -> new Pending(v.digest(), v.expiresAt(), v.guesses() + 1));   // atomic count
        if (p == null || p.guesses() > MAX_GUESSES || Instant.now().isAfter(p.expiresAt())) {
            return false;
        }
        if (!MessageDigest.isEqual(p.digest(), hmac(phone, code))) {
            return false;
        }
        pending.remove(phone);                                   // single use
        return true;
    }

    private byte[] hmac(String phone, String code) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(pepper, "HmacSHA256"));
            return mac.doFinal((phone + ":" + code).getBytes(StandardCharsets.UTF_8));
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        }
    }
}

SecureRandom draws from the operating system's entropy source. java.util.Random is a linear congruential generator, and its next output can be predicted from a few previous ones. computeIfPresent increments the guess counter atomically, so parallel requests cannot squeeze in extra guesses. MessageDigest.isEqual compares in constant time. Keep a per-number cap on issuing codes too, around three per 10 minutes, because an open OTP endpoint is a target for SMS pumping. For high-volume login traffic, see our OTP SMS service.

11. Troubleshooting Java SMS integrations

SymptomLikely causeFix
Could not resolve placeholder 'SMS_API_KEY'Environment variable not set for the JVMSet it in the systemd unit, container or IDE run configuration
401 Invalid API keyWrong or revoked key, or stray whitespaceRegenerate the key in the dashboard; trim() when reading it
TEMPLATE_MISMATCHText differs from the approved template, or a tagged variable failed validationFill with DltTemplate.fill(); see DLT error codes
HttpTimeoutException: request timed outSent, but no response within the read timeoutDo not resend blindly; check status or the dashboard
@Retryable never retriesNo @EnableRetry, no AOP, or self-invocationSee the callout in section 7
PKIX path building failedOld JDK trust store or an intercepting corporate proxyUpdate the JDK or import the proxy CA; never install a trust-all TrustManager
UnrecognizedPropertyExceptionA plain ObjectMapper meets a field your record lacksKeep @JsonIgnoreProperties(ignoreUnknown = true) on response records
Tamil arrives as ????Source compiled with a non-UTF-8 encoding, or unicode not setSet project.build.sourceEncoding to UTF-8; send unicode: true

Other stacks: PHP, Laravel, Python and Node.js. All are listed in our developer guides.

FAQ

Sending SMS from Java and Spring Boot: FAQ

How do I send an SMS from Java without a third-party library?
Use java.net.http.HttpClient, which has been in the JDK since Java 11. Build a POST request with a JSON body, an Authorization: Bearer header, a connect timeout and a request timeout, then call send(). The only extra library worth adding is Jackson, so you do not build JSON by hand.
Should I use RestClient, WebClient or RestTemplate in Spring Boot?
For a normal servlet application on Spring Boot 3.2 or later, use RestClient, the synchronous client added in Spring Framework 6.1. Use WebClient only if your application is already reactive. RestTemplate still works, but new code should use RestClient.
Why is my @Retryable method not retrying?
The usual causes are a missing @EnableRetry, no AOP on the classpath (spring-boot-starter-aop), or calling the method from inside the same class, which bypasses the Spring proxy. Also check that the exception thrown matches retryFor.
How do I generate a secure OTP in Java?
Use java.security.SecureRandom, never java.util.Random or Math.random(). Store only an HMAC of the code with an expiry of about 10 minutes, compare with MessageDigest.isEqual(), and cap guesses and send requests per number. See our OTP SMS service for delivery.
Why does String.length() not match the characters I see in Tamil text?
Java strings are UTF-16. length() counts UTF-16 code units, Tamil vowel signs are separate code points, and an emoji takes two units. For SMS billing that is the right measure, because a Unicode (UCS-2) SMS is counted in the same 16-bit units: 70 for one part, 67 per part when split.
Is there a Java SDK for the AvanceZone SMS API?
The API is one HTTPS call with JSON in and out, so the classes on this page are all most projects need. The same API is shown in Python, Node.js and PHP.
READY TO INTEGRATE?

Get an API Key and Send Your First SMS from Java

Open an account and we help with DLT registration and template approval, so your Spring service has approved templates to send from day one.