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.
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.
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.SMS_API_KEY environment variable. Keep it out of application.yml in Git.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.No dependencies, one file. Java 11+ can run a single source file directly with java SendSms.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.
{
"success": true,
"message": "SMS queued successfully",
"data": {
"message_id": 1849302,
"queue_id": 1849302,
"status": "queued",
"estimated_delivery": "2026-10-29T10:42:05.000000Z"
}
}| Outcome | Meaning | Retry the send? |
|---|---|---|
| 200 | Accepted and queued, not yet delivered | Store message_id |
| 400 / 401 / 422 | Invalid sender ID, invalid API key, validation error or TEMPLATE_MISMATCH | No. Fix the request |
| 429, 502, 503 | Rate limit, or the gateway is briefly unavailable | Yes, with back-off |
500, 504, HttpTimeoutException | The message may already be queued | Not automatically. Check first |
ConnectException, HttpConnectTimeoutException | Never reached the gateway | Yes |
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:
// 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.
In Spring Boot, keep the settings in one immutable record. Boot 3 binds records through their constructor, with no setters and no @ConstructorBinding:
# 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// 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:
// 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.
Records map the JSON in both directions. Package-private records can share one file:
// 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:
// 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"
}
}// 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.
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.
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.
{#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.{#var#} allows 30. Confirm on your operator's portal.// 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.
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.
// 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.
// 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.
| Symptom | Likely cause | Fix |
|---|---|---|
Could not resolve placeholder 'SMS_API_KEY' | Environment variable not set for the JVM | Set it in the systemd unit, container or IDE run configuration |
401 Invalid API key | Wrong or revoked key, or stray whitespace | Regenerate the key in the dashboard; trim() when reading it |
TEMPLATE_MISMATCH | Text differs from the approved template, or a tagged variable failed validation | Fill with DltTemplate.fill(); see DLT error codes |
HttpTimeoutException: request timed out | Sent, but no response within the read timeout | Do not resend blindly; check status or the dashboard |
@Retryable never retries | No @EnableRetry, no AOP, or self-invocation | See the callout in section 7 |
PKIX path building failed | Old JDK trust store or an intercepting corporate proxy | Update the JDK or import the proxy CA; never install a trust-all TrustManager |
UnrecognizedPropertyException | A plain ObjectMapper meets a field your record lacks | Keep @JsonIgnoreProperties(ignoreUnknown = true) on response records |
Tamil arrives as ???? | Source compiled with a non-UTF-8 encoding, or unicode not set | Set project.build.sourceEncoding to UTF-8; send unicode: true |
Other stacks: PHP, Laravel, Python and Node.js. All are listed in our developer guides.
Open an account and we help with DLT registration and template approval, so your Spring service has approved templates to send from day one.