SMSGatewayCenter Blog

Messaging in Java and Spring Boot: The Full Integration Guide (2026)

A production Java and Spring Boot integration for SMS, OTP, WhatsApp and delivery reports. Timeouts that are not set by default, form encoding that fails as an auth error, Jackson deserializers for an inconsistent response contract, Spring Retry that must not retry, OAuth 2.0 with PKCE, Resilience4j rate limiting, and JUnit tests that never bill you.

Featured image for Messaging in Java and Spring Boot: The Full Integration Guide (2026)

Table of Contents

  1. Executive summary
  2. TL;DR
  3. Why Java needs its own guide
  4. Is there an official Java SDK
  5. Choosing the HTTP client
  6. The timeout problem, in full
  7. Connect timeout versus read timeout
  8. Form encoding, and why JSON looks like an auth failure
  9. Jackson at the boundary
  10. The response contract is not consistent, and Java notices first
  11. Configuration and credentials
  12. The send client
  13. Authentication: API key, and now OAuth 2.0 with PKCE
  14. Counting segments correctly in Java
  15. The India DLT layer
  16. The appendReplacement trap
  17. Spring Retry, configured not to retry
  18. Claim before send
  19. Batching and the recipient list
  20. OTP in Spring Boot
  21. WhatsApp from the same codebase
  22. Delivery reports: the webhook receiver
  23. Delivery reports: the poller
  24. Resilience4j: rate limiter, bulkhead and circuit breaker
  25. Micrometer instrumentation
  26. Testing with JUnit 5 and MockWebServer
  27. Security and secret handling
  28. The ten-step build order
  29. Decision matrix
  30. Production checklist
  31. Ten mistakes
  32. What this article deliberately does not claim
  33. FAQ

Executive summary

To send an SMS from Java you POST an application/x-www-form-urlencoded body to https://unify.smsgateway.center/SMSApi/send with userid, password or an apiKey header, sendMethod=quick, mobile, msg, senderid, msgType and output=json. That part takes twenty minutes. The remaining ninety percent of a production integration is defending against the defaults in the Java HTTP stack, none of which were designed for an operation that costs money the instant it is accepted.

Geometric illustration of a Java application stack sending messages through a queue to mobile devices with delivery receipts returning

Java has a specific and unusually dangerous set of defaults here. WebClient on Reactor Netty applies no response timeout unless you configure one. RestTemplate with a bare SimpleClientHttpRequestFactory applies no timeout either. Spring Retry, added in one annotation, will happily replay a POST that was already accepted upstream and bill you twice. Jackson binds by property name, which means the moment a response field arrives as Channel Name with a space in it, or as a quoted string where a sibling endpoint returns an unquoted number, your deserializer throws in production and not in your test. Matcher.appendReplacement treats a dollar sign in a replacement string as a group reference, which will silently corrupt a DLT variable substitution when a customer’s name or an order reference contains one.

This guide walks the full build, in Java 17 and Spring Boot 3, against the real documented endpoints. It is the Java sibling to the PHP, Node.js and Python tutorials, and it does not repeat their material. Where a concept is fully covered elsewhere on this site, this article links there and moves on.


TL;DR

  • Use WebClient, and set both a connect timeout and a response timeout explicitly. Neither exists by default.
  • Build the body as a MultiValueMap<String, String> and send it as APPLICATION_FORM_URLENCODED. Sending JSON to this endpoint does not fail as a 400. It fails as an authentication error, because the parameters simply are not there.
  • Configure Jackson to tolerate an inconsistent contract. statusCode arrives quoted from SMSApi/send and unquoted from WAApi/send. The by-transaction report endpoint returns field names containing spaces and mixed capitalisation. Both need explicit handling.
  • Turn Spring Retry off on the send path. Retry the read endpoints, never the write.
  • Count segments with codePointCount, not length. Java strings are UTF-16 and length counts code units.
  • Render DLT variables by positional substitution, not by String.replaceAll and not by Matcher.appendReplacement without Matcher.quoteReplacement.
  • Claim the row in the database before the HTTP call, inside a transaction, behind a unique constraint. An ambiguous outcome moves to UNKNOWN and never back to PENDING.
  • Verify the webhook signature over the raw bytes with @RequestBody byte[] and MessageDigest.isEqual, before any parsing.
  • Use Resilience4j for the rate limiter and the bulkhead. Do not use a circuit breaker on the send path without understanding what it does to a queue.
  • Test at the transport layer with MockWebServer. A test suite that really sends is a test suite that really bills.

Why Java needs its own guide

There is a reasonable objection to a per-language tutorial: HTTP is HTTP, so why not write one article and translate the snippets. The objection is wrong for the same reason it was wrong in Node.js and Python, and it is more wrong in Java. The failure modes in a messaging integration are almost entirely defaults, and defaults are a property of the language and its ecosystem, not of the protocol.

Five things are specifically Java-shaped here.

Strings are UTF-16. "😀".length() is 2 in Java. Every naive segment counter written in Java is wrong for exactly the characters that trigger the expensive Unicode encoding, and it is wrong in the direction that undercounts, which means you discover it on the invoice rather than in the test.

Jackson binds by name and by type. Loosely typed languages shrug at a field that is sometimes "200" and sometimes 200. Java throws MismatchedInputException at runtime, from a code path that only executes for one of the two channels, which is a beautiful way to ship a bug that passes every test.

Spring makes retry a one-line decision. @Retryable is a single annotation. It does not know that the operation it is wrapping is a billed, non-idempotent write. Every other language in this series requires you to install and wire a retry library; Java hands it to you before you have thought about it.

Reactor Netty’s default is no timeout. This is the same trap as fetch in Node, but it is worse in a Spring application, because the hang consumes a slot in a bounded connection pool and a slot in a bounded thread pool. One upstream stall becomes a saturated application rather than one slow request.

Regex replacement strings are interpreted. $ and \ are metacharacters in a Java replacement string. This is the exact analogue of the JavaScript $& hazard documented in the Node.js tutorial, and it lands in DLT variable rendering, which is the one place in an Indian SMS integration where a corrupted character does not just look bad, it fails template matching and the message is rejected.


Is there an official Java SDK

No. As of the date on this article, the SDK section of the developer documentation lists a PHP SDK and a Python SDK, and nothing else. There is a Postman collection which is useful as a source of request fixtures, but there is no Maven or Gradle artifact to depend on.

That is not a problem. The API surface is small, form encoded and stable, and the client you are about to write is about a hundred and fifty lines. A thin hand-written client that you control is a better outcome here than a dependency, because every hard part of this integration is in configuration you would have to override anyway.

If you want a search across the whole endpoint surface before you start, the SMS messaging API index enumerates every endpoint in one page.


Choosing the HTTP client

ClientDefault connect timeoutDefault read or response timeoutVerdict for messaging
RestTemplate with SimpleClientHttpRequestFactorynonenoneDo not use unconfigured. Two silent infinities.
RestTemplate with HttpComponentsClientHttpRequestFactoryfrom the Apache client configfrom the Apache client configAcceptable if you configure both explicitly.
WebClient on Reactor Nettynone unless set on the HttpClientnone unless responseTimeout is setRecommended, but only after configuration.
RestClient (Spring 6.1+)delegates to the underlying factorydelegates to the underlying factoryFine. Same configuration burden.
java.net.http.HttpClientnonenone unless .timeout() per requestUsable. The per-request timeout is easy to forget.

The recommendation is WebClient, for one reason that has nothing to do with reactive programming: it is the client where the timeout configuration is centralised in one bean, so it is the client where you can prove the timeout is set by reading a single file. You can call it with .block() from an ordinary imperative service and lose nothing that matters here.

The trap to avoid is thinking that because you are not writing reactive code, RestTemplate is simpler. It is not simpler. It has the same two infinite defaults and it scatters the fix across every call site.


The timeout problem, in full

A messaging send is a request that costs money and that you cannot safely repeat. That inverts the usual timeout calculus. In an ordinary API integration, a long timeout is merely slow. Here, a long timeout is a slot held open in a pool while a billable operation may or may not have completed upstream, and the longer you hold it the less you know.

Here is the client bean. Every number in it is a decision, not a default.

package com.example.messaging.http;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class MessagingHttpConfig {

    @Bean
    ConnectionProvider messagingConnectionProvider() {
        return ConnectionProvider.builder("sgc")
                .maxConnections(50)
                // Fail fast rather than queue forever when the pool is exhausted.
                .pendingAcquireTimeout(Duration.ofSeconds(3))
                .pendingAcquireMaxCount(200)
                .maxIdleTime(Duration.ofSeconds(30))
                .maxLifeTime(Duration.ofMinutes(10))
                // Cheap insurance against a half-open connection returned from the pool.
                .evictInBackground(Duration.ofSeconds(30))
                .build();
    }

    @Bean
    WebClient messagingWebClient(ConnectionProvider provider) {
        HttpClient httpClient = HttpClient.create(provider)
                // TCP connect only. Does not cover TLS negotiation or the response.
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000)
                // The one that actually matters. Absent by default.
                .responseTimeout(Duration.ofSeconds(15))
                .doOnConnected(conn -> conn
                        .addHandlerLast(new ReadTimeoutHandler(15, TimeUnit.SECONDS))
                        .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS)))
                .compress(true);

        return WebClient.builder()
                .baseUrl("https://unify.smsgateway.center")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .defaultHeader("Content-Type", MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .exchangeStrategies(ExchangeStrategies.builder()
                        .codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
                        .build())
                .build();
    }
}

Three notes on that bean.

responseTimeout is the line that most Spring Boot messaging integrations are missing. Without it, a request that reaches the server and then stalls will hang until the socket is closed by something outside your process. There is no framework default that saves you.

pendingAcquireTimeout matters more than it looks. When the pool is exhausted, the default behaviour is to queue, and a queued request has not started, so no response timeout is running on it. Set it low. Failing to acquire a connection is an unambiguously safe failure: nothing was submitted, so a retry is free.

maxInMemorySize is raised because a delivery report page can be large. The default 256 KB buffer will truncate a report response and produce a decode error that reads as a parsing bug.


Connect timeout versus read timeout

Illustration about Connect timeout versus read timeout

This is the most important table in the article, and it is the one that decides whether your retry logic bills the customer twice.

Java exceptionWhat happenedWas the message submittedSafe to retry
ConnectTimeoutExceptionTCP handshake never completedNoYes, immediately
UnknownHostExceptionDNS failedNoYes, after DNS recovers
SSLHandshakeExceptionTLS negotiation failedNoYes, but fix the trust store first
WebClientRequestException wrapping ConnectExceptionConnection refusedNoYes
ReadTimeoutExceptionRequest was written, no response arrivedUnknownNo
PrematureCloseException after request writeConnection dropped mid-responseUnknownNo
HTTP 5xx with a bodyServer respondedAlmost certainly not, but confirmUsually yes
HTTP 200 with status of errorServer responded and rejected itNoYes, after fixing the cause

The distinction that Java makes easy and that most integrations throw away: ConnectTimeoutException and ReadTimeoutException are different classes. Do not catch Exception on the send path. Do not catch WebClientRequestException and treat every instance the same. Classify.

package com.example.messaging.http;

import io.netty.handler.timeout.ReadTimeoutException;
import io.netty.channel.ConnectTimeoutException;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import reactor.netty.http.client.PrematureCloseException;

import java.net.ConnectException;
import java.net.UnknownHostException;

public enum SendOutcome {
    NOT_SUBMITTED,   // provably nothing reached the platform
    SUBMITTED,       // a response was parsed
    UNKNOWN;         // it may or may not have been accepted and billed

    public static SendOutcome classify(Throwable t) {
        Throwable cause = t instanceof WebClientRequestException && t.getCause() != null
                ? t.getCause()
                : t;

        if (cause instanceof ConnectTimeoutException
                || cause instanceof ConnectException
                || cause instanceof UnknownHostException) {
            return NOT_SUBMITTED;
        }
        if (cause instanceof ReadTimeoutException
                || cause instanceof PrematureCloseException) {
            return UNKNOWN;
        }
        // Anything unrecognised is UNKNOWN. Defaulting to NOT_SUBMITTED here
        // is how a library upgrade turns into a duplicate billing incident.
        return UNKNOWN;
    }
}

The default branch is deliberate. If a future version of Reactor Netty introduces an exception type this enum has never seen, the safe assumption is that the message may have been sent. Treating an unknown failure as a definite non-send is optimistic in the direction that costs money.

The full treatment of what to do once an outcome is UNKNOWN lives in Message Idempotency at Scale. This article implements the Java side of that design in the claim-before-send section below.


Form encoding, and why JSON looks like an auth failure

The send endpoint accepts application/x-www-form-urlencoded. It also documents JSON and XML request bodies for the batch shape, using an sms array. What it does not do is accept an arbitrary JSON object with your parameters at the top level.

This is worth spelling out because the failure is misleading. If you send {"userid":"...","password":"...","mobile":"..."} as application/json to the quick-send path, the platform does not see a malformed request. It sees a request with no userid and no password, because those parameters were never parsed out of a body it was not asked to parse that way. The response is an authentication failure. Developers then spend an afternoon rotating credentials.

In Spring, the fix is to build a MultiValueMap and let BodyInserters.fromFormData encode it.

MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("userid", credentials.userid());
form.add("password", credentials.password());
form.add("sendMethod", "quick");
form.add("msgType", "text");
form.add("senderid", senderId);
form.add("mobile", String.join(",", recipients));   // comma separated
form.add("msg", body);
form.add("output", "json");
form.add("duplicatecheck", "true");

webClient.post()
        .uri("/SMSApi/send")
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .body(BodyInserters.fromFormData(form))
        .retrieve();

Two details that are easy to get wrong.

Do not URL-encode the values yourself before adding them to the map. fromFormData encodes. Pre-encoding produces double-encoded percent signs, and a message body containing a percent sign is common enough that you will hit it.

The recipient delimiter is a comma. The documentation describes “lists of comma-separated mobile numbers” and its own samples tolerate a space after the comma. Use String.join(",", ...) and do not invent a semicolon or a newline separator.

Finally, note that the password must be URL-encoded when it contains special characters. fromFormData handles this correctly. Hand-built query strings do not, and a password containing a + will authenticate as a password containing a space.


Jackson at the boundary

The single most Java-specific piece of work in this integration is making Jackson survive a response contract that was not designed with a statically typed binder in mind.

Start with an ObjectMapper that is deliberately lenient about shape and strict about nothing else.

@Bean
@Qualifier("messaging")
ObjectMapper messagingObjectMapper() {
    return JsonMapper.builder()
            // New optional fields must not break an existing deployment.
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            // "" for an absent numeric field is common in this contract.
            .enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)
            // Single-element arrays sometimes arrive unwrapped.
            .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
            .serializationInclusion(JsonInclude.Include.NON_NULL)
            .build();
}

FAIL_ON_UNKNOWN_PROPERTIES is disabled for a specific operational reason, not out of laziness. The platform can add optional response fields without a version bump. If your deserializer is strict, that addition is an outage in your application, at a moment when nothing in your own deployment changed. Forward compatibility on a response you do not control is worth more than the type safety you give up.


The response contract is not consistent, and Java notices first

Three concrete inconsistencies, all of which are harmless in a dynamic language and all of which are a runtime exception in Java.

One. statusCode is quoted on SMS and unquoted on WhatsApp.

The documented SMSApi/send success response is:

{"status":"success","mobile":"919999999999","invalidMobile":"","transactionId":"6305583318236810379","statusCode":"200","reason":"success"}

The documented WAApi/send success response is:

{"status":"success","messageId":"1234567890","mobile":"919xxxxxxxxx6","statusCode":200,"description":"Message sent successfully"}

Same field name, different JSON type. Also note that the identifier is transactionId on one and messageId on the other, and the human-readable field is reason on one and description on the other. Bind statusCode with a deserializer that accepts either.

public class LenientStringDeserializer extends JsonDeserializer<String> {
    @Override
    public String deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
        JsonToken t = p.currentToken();
        if (t == JsonToken.VALUE_NUMBER_INT || t == JsonToken.VALUE_NUMBER_FLOAT) {
            return p.getText();
        }
        if (t == JsonToken.VALUE_STRING) {
            String s = p.getText();
            return s == null ? null : s.trim();
        }
        if (t == JsonToken.VALUE_NULL) {
            return null;
        }
        return p.getValueAsString();
    }
}

Then normalise both channels onto one internal record, so that nothing downstream of the client ever has to know which channel it came from.

public record SubmitResult(
        String status,          // "success" | "error"
        String statusCode,      // always a String internally
        String providerId,      // transactionId or messageId
        String detail,          // reason or description
        String invalidMobile,   // SMS only, empty string when none
        String rawBody          // keep it, you will want it during an incident
) {
    public boolean accepted() {
        return "success".equalsIgnoreCase(status);
    }
}

Keeping rawBody is not sentimentality. When an unexplained charge appears on an invoice three weeks later, the raw response is the only artefact that proves what the platform said at submission time.

Two. The by-transaction report endpoint returns field names with spaces.

This one is new and it is the sharpest Java hazard in the whole contract. The Check SMS Delivery Report by Transaction ID endpoint, which is SMSApi/reports/status called with a uuid plus fromdate and todate, returns a documented shape like this:

{
  "response": {
    "api": "send",
    "action": "status",
    "status": "success",
    "msg": "success",
    "code": "200",
    "count": 1,
    "report_statusList": [
      {
        "status": {
          "uuId": "8359251506264886974",
          "msgId": "QudUrwhMuIGkbs2",
          "mobileNo": "919999999999",
          "senderName": "CBISTS",
          "text": "Hello world",
          "msgType": "text",
          "length": "11",
          "cost": "1",
          "Status": "FAILED",
          "Cause": "Unknown User",
          "Channel Name": "API",
          "Submitted Time": "July 13, 2019 03:44:48",
          "Delivered Time": "July 13, 2019 03:44:49"
        }
      }
    ]
  }
}

Look at what that does to a POJO. status appears twice at different nesting levels with different meanings, once as an envelope field and once as the delivery state. Status and status differ only by capitalisation and mean different things in the same object. Channel Name, Submitted Time and Delivered Time contain spaces, so there is no legal Java identifier that Jackson can infer from them. And each array element is a wrapper object whose single key is status.

No amount of naming strategy configuration fixes this. Every field needs an explicit @JsonProperty.

public class TransactionStatusRow {
    @JsonProperty("uuId")          public String uuId;
    @JsonProperty("msgId")         public String msgId;
    @JsonProperty("mobileNo")      public String mobileNo;
    @JsonProperty("senderName")    public String senderName;
    @JsonProperty("text")          public String text;
    @JsonProperty("msgType")       public String msgType;
    @JsonProperty("length")        public String length;
    @JsonProperty("cost")          public String cost;   // parse to BigDecimal, never double
    @JsonProperty("Status")        public String deliveryStatus;
    @JsonProperty("Cause")         public String cause;
    @JsonProperty("Channel Name")  public String channelName;
    @JsonProperty("Submitted Time") public String submittedTime;
    @JsonProperty("Delivered Time") public String deliveredTime;
}

public class TransactionStatusWrapper {
    @JsonProperty("status") public TransactionStatusRow status;
}

Note cost as a String. Parse it into BigDecimal, never double. Currency in a binary floating point type is a defect waiting for a reconciliation report.

Three. The same parameter is spelled two ways across the docs.

The send documentation uses lowercase duplicatecheck in its parameter table and in some samples, and camelCase duplicateCheck in others. Send the lowercase form, which is the one the parameter table specifies. This is worth reporting to support rather than working around silently.

While you are here: duplicatecheck means “remove duplicate mobile numbers” within a single request, and its default is true. It is within-request recipient deduplication. It is not a cross-request retry guard and there is no documented time window on it. If you have read otherwise anywhere, that is the correction.


Configuration and credentials

@ConfigurationProperties(prefix = "sgc")
@Validated
public record SgcProperties(
        @NotBlank String baseUrl,
        @NotBlank String userid,
        String password,
        String apiKey,
        @NotBlank String senderId,
        String dltEntityId,
        Duration responseTimeout,
        int maxRecipientsPerRequest
) {
    public SgcProperties {
        if ((password == null || password.isBlank()) && (apiKey == null || apiKey.isBlank())) {
            throw new IllegalStateException("Set exactly one of sgc.password or sgc.api-key");
        }
        if (maxRecipientsPerRequest <= 0) {
            maxRecipientsPerRequest = 1000;
        }
    }

    // Prevent the credential leaking through a logged config dump.
    @Override
    public String toString() {
        return "SgcProperties[baseUrl=%s, userid=%s, senderId=%s, password=***, apiKey=***]"
                .formatted(baseUrl, userid, senderId);
    }
}

Overriding toString on a record is the Java equivalent of the __debugInfo redaction used in the PHP tutorial and the logging filter used in the Python one. Spring Boot’s actuator, a @ConfigurationProperties binding failure message, and half the logging in a typical application will call toString on this object at some point. Records generate a toString that prints every component, including the password. Override it.

For key management specifically, see the guide to creating and using your API key. The apiKey goes in an HTTP header, not in the form body, which is a meaningful security improvement because it keeps the credential out of any component that logs request bodies.


The send client

@Service
public class SgcSmsClient {

    private static final Logger log = LoggerFactory.getLogger(SgcSmsClient.class);

    private final WebClient webClient;
    private final SgcProperties props;
    private final ObjectMapper mapper;

    public SgcSmsClient(WebClient messagingWebClient,
                        SgcProperties props,
                        @Qualifier("messaging") ObjectMapper mapper) {
        this.webClient = messagingWebClient;
        this.props = props;
        this.mapper = mapper;
    }

    /**
     * Submits one send request. Throws nothing that a caller can safely retry
     * without first consulting SendOutcome.classify.
     */
    public SubmitResult send(SendRequest req) {
        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        applyAuth(form);
        form.add("sendMethod", "quick");
        form.add("msgType", req.unicode() ? "unicode" : "text");
        form.add("senderid", req.senderId() != null ? req.senderId() : props.senderId());
        form.add("mobile", String.join(",", req.recipients()));
        form.add("msg", req.body());
        form.add("output", "json");
        form.add("duplicatecheck", "true");

        if (req.dltTemplateId() != null) {
            form.add("dltTemplateId", req.dltTemplateId());
            if (props.dltEntityId() != null) {
                form.add("dltEntityId", props.dltEntityId());
            }
        }
        if (req.scheduleTime() != null) {
            // Documented format: YYYY-MM-DD HH:MM:SS
            form.add("scheduleTime",
                    req.scheduleTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        }
        if (req.testMode()) {
            form.add("testMessage", "true");
        }

        WebClient.RequestBodySpec spec = webClient.post()
                .uri("/SMSApi/send")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED);

        if (props.apiKey() != null && !props.apiKey().isBlank()) {
            spec = spec.header("apiKey", props.apiKey());
        }

        String raw = spec.body(BodyInserters.fromFormData(form))
                // Do NOT use onStatus to throw here. A non-2xx body still
                // carries a statusCode and reason worth recording.
                .exchangeToMono(resp -> resp.bodyToMono(String.class)
                        .defaultIfEmpty("")
                        .map(b -> b))
                .block();

        return parse(raw);
    }

    private void applyAuth(MultiValueMap<String, String> form) {
        form.add("userid", props.userid());
        if (props.apiKey() == null || props.apiKey().isBlank()) {
            form.add("password", props.password());
        }
    }

    private SubmitResult parse(String raw) {
        try {
            JsonNode n = mapper.readTree(raw == null ? "" : raw);
            return new SubmitResult(
                    text(n, "status"),
                    text(n, "statusCode"),
                    firstNonNull(text(n, "transactionId"), text(n, "messageId")),
                    firstNonNull(text(n, "reason"), text(n, "description")),
                    text(n, "invalidMobile"),
                    raw
            );
        } catch (JsonProcessingException e) {
            // A body that will not parse is not a success. Treat as UNKNOWN upstream.
            throw new AmbiguousSubmitException("unparseable response body", e, raw);
        }
    }

    private static String text(JsonNode n, String field) {
        JsonNode v = n.get(field);
        return v == null || v.isNull() ? null : v.asText();
    }

    private static String firstNonNull(String a, String b) {
        return a != null ? a : b;
    }
}

The one thing in that listing that will look wrong to an experienced Spring developer is exchangeToMono returning the raw body instead of retrieve().bodyToMono(...) with an onStatus handler that throws. That is deliberate. retrieve() with the default status handler converts a 4xx or 5xx into a WebClientResponseException and, depending on how you configure it, discards or buries the body. In this API the body is the diagnostic. A rejection carries a statusCode and a reason that tell you whether the sender ID is blocked, the template mismatched, or the balance is exhausted. Throwing that away and reporting “500 Internal Server Error” to your operations team is a self-inflicted wound.

Note also invalidMobile. On a multi-recipient request, the top-level status can read success while individual numbers were rejected and enumerated in invalidMobile. If you treat status == success as “all recipients accepted”, you will silently drop recipients. Parse invalidMobile, and if it is non-empty, mark those specific rows as rejected rather than submitted.


Authentication: API key, and now OAuth 2.0 with PKCE

Most integrations use one of two mechanisms, both documented on the authentication page: userid plus password as form parameters, or an apiKey sent as an HTTP header while userid stays in the body. For a single-tenant application that sends on its own account, the API key is the right answer, and you can create one from the panel or through the Create API Key endpoint.

There is now a third mechanism, and it changes what a Java integration can be. The platform documents a full REST OAuth API implementing OAuth 2.0 Authorization Code with PKCE. This matters if you are building a product that sends on behalf of other people’s accounts: an agency dashboard, a CRM connector, an internal platform where each business unit has its own account. Previously that meant asking customers to paste an API key into your application, which is both a support burden and a security posture nobody enjoys defending.

The documented endpoints are:

EndpointMethodPurpose
https://unify.smsgateway.center/rest/oauth/v1/authorizeGETStart user sign-in and authorization
https://unify.smsgateway.center/rest/oauth/v1/tokenPOSTExchange an authorization code or refresh token
https://unify.smsgateway.center/rest/oauth/v1/revokePOSTRevoke a refresh token or access token
https://unify.smsgateway.center/rest/oauth/v1/introspectPOSTCheck whether an access token is active and resolve the username

Documented token lifetimes: the authorization code lasts 10 minutes, the access token 3600 seconds, and the refresh token 30 days. Only the S256 code challenge method is supported. Client registration is manual, through support.

There is one integration detail here that will trip up anyone who has wired OAuth before, and it is the thing to internalise. The access token does not carry the account identity for the SMS APIs. The token response returns access_token, refresh_token and username, and the documentation is explicit: you must send the access token as a bearer header or as the apikey header, and you must still send userid set to that username on every SMS API request. OAuth replaces credential entry. It does not replace the request format.

Concretely, after the exchange your send call looks like this:

curl -s -X POST "https://unify.smsgateway.center/SMSApi/send" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "userid=USERNAME_FROM_TOKEN_RESPONSE" \
  --data-urlencode "output=json" \
  --data-urlencode "sendMethod=quick" \
  --data-urlencode "mobile=919999999999" \
  --data-urlencode "senderid=YOUR_SENDER_ID" \
  --data-urlencode "msg=Your order is ready." \
  --data-urlencode "msgType=text" \
  --data-urlencode "duplicatecheck=true"

In Spring, the natural home for this is spring-boot-starter-oauth2-client with a registration whose provider points at those URIs. The one adjustment is that the token response contains a non-standard username claim you need to persist alongside the tokens, so a custom OAuth2AccessTokenResponseClient or a post-exchange listener is required.

@Bean
ClientRegistrationRepository sgcClientRegistrations(
        @Value("${sgc.oauth.client-id}") String clientId,
        @Value("${sgc.oauth.client-secret}") String clientSecret,
        @Value("${sgc.oauth.redirect-uri}") String redirectUri) {

    ClientRegistration sgc = ClientRegistration.withRegistrationId("sgc")
            .clientId(clientId)
            .clientSecret(clientSecret)
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .redirectUri(redirectUri)
            .authorizationUri("https://unify.smsgateway.center/rest/oauth/v1/authorize")
            .tokenUri("https://unify.smsgateway.center/rest/oauth/v1/token")
            .clientName("SMSGatewayCenter")
            .build();

    return new InMemoryClientRegistrationRepository(sgc);
}

// PKCE is not enabled by default for confidential clients in Spring Security.
// Only S256 is supported by the platform, which is what this resolver emits.
@Bean
OAuth2AuthorizationRequestResolver pkceResolver(ClientRegistrationRepository repo) {
    DefaultOAuth2AuthorizationRequestResolver resolver =
            new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
    resolver.setAuthorizationRequestCustomizer(
            OAuth2AuthorizationRequestCustomizers.withPkce());
    return resolver;
}

OAuth2AuthorizationRequestCustomizers.withPkce() is the line people miss. Spring Security enables PKCE automatically for public clients, but this is a confidential client with a secret, so you have to opt in explicitly. Without it the authorize request carries no code_challenge and the token exchange fails with invalid_grant, which the documented error table attributes to a code that has expired, been used, or has a PKCE verifier mismatch. The last of those is what you are hitting.

Store username with the token. A token store that keeps only access_token and refresh_token will leave you with a valid credential and no idea which userid to put in the form body. The introspection endpoint exists to recover from exactly that situation, returning {"active": true, "username": "...", "token_type": "Bearer"}, but treating it as a routine per-request lookup is an unnecessary round trip.

Refresh proactively, not reactively. With a one-hour access token and a scheduled batch that runs for ninety minutes, a reactive refresh means the failure surfaces as an authentication error in the middle of a billed send loop, which is precisely the ambiguity you spent this whole article avoiding. Refresh on a timer at, say, fifty minutes.


Counting segments correctly in Java

You are billed by segment, not by message. The text messages chart documents the mapping: 160 characters per segment for English and 153 when concatenated, 70 per segment for Unicode and 67 when concatenated.

The Java trap is that String.length() returns the number of UTF-16 code units, not characters. Any character outside the Basic Multilingual Plane, which includes every emoji anyone will ever paste into a marketing message, counts as two. So the naive counter overcounts emoji in the UTF-16 sense while a naive GSM-7 detector undercounts the cost of a single accented character. Both errors matter and they point in different directions.

public final class SegmentCounter {

    private static final String GSM_BASIC =
        "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
      + "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";

    // Each of these occupies TWO GSM-7 septets because of the escape sequence.
    private static final String GSM_EXTENSION = "^{}\\[~]|€";

    public record Count(String encoding, int units, int segments, int perSegment) {}

    public static Count count(String msg) {
        if (msg == null) msg = "";

        boolean gsm = true;
        int septets = 0;

        // Iterate by CODE POINT. This is the whole point of the class.
        for (int i = 0; i < msg.length(); ) {
            int cp = msg.codePointAt(i);
            i += Character.charCount(cp);

            String s = new String(Character.toChars(cp));
            if (GSM_EXTENSION.contains(s)) {
                septets += 2;
            } else if (GSM_BASIC.contains(s)) {
                septets += 1;
            } else {
                gsm = false;
                break;
            }
        }

        if (gsm) {
            int segments = septets <= 160 ? (septets == 0 ? 1 : 1)
                                          : (int) Math.ceil(septets / 153.0);
            return new Count("TEXT", septets, Math.max(segments, 1), septets <= 160 ? 160 : 153);
        }

        // Unicode is billed in UTF-16 code units, so length() is correct HERE
        // and only here. An emoji genuinely occupies two units on the wire.
        int units = msg.length();
        int segments = units <= 70 ? 1 : (int) Math.ceil(units / 67.0);
        return new Count("UNICODE", units, Math.max(segments, 1), units <= 70 ? 70 : 67);
    }
}

The comment in the Unicode branch is the subtle part. length() is wrong for counting characters and right for counting UCS-2 units on the wire, because a surrogate pair really does consume two units of the seventy. So the correct implementation uses code points in the GSM branch and code units in the Unicode branch. Getting this backwards in either direction produces a counter that disagrees with the invoice.

Cross-check your implementation against the platform’s own SMS length and cost endpoint, which POSTs a msg and returns encoding, length, remaining and credit. Do this in CI over a fixture of a few hundred real message bodies, not at send time. Calling it per send adds a round trip to the critical path for a value you can compute locally.

For the full economics of why this matters, including the specific punctuation characters that silently push a message into Unicode, see Bulk SMS Pricing in India: What Actually Drives Cost.


The India DLT layer

If you send to Indian numbers, the message body must match a template registered on a DLT portal, and the send must carry dltTemplateId and dltEntityId. Registration itself is covered in the DLT registration guide, and the reasons templates get rejected are catalogued in DLT Template Rejected: Every Reason and How to Fix It. What follows is only the Java rendering layer.

A registered template looks like Dear {#var#}, your order {#var#} has shipped. Track at {#var#}. The variables are positional and identical. There is no name to key on. This is why every correct renderer in this series is positional.

public final class DltTemplate {

    private static final String TOKEN = "{#var#}";

    private final String pattern;
    private final int arity;

    public DltTemplate(String pattern) {
        this.pattern = Objects.requireNonNull(pattern);
        int n = 0, idx = 0;
        while ((idx = pattern.indexOf(TOKEN, idx)) >= 0) { n++; idx += TOKEN.length(); }
        this.arity = n;
    }

    public int arity() { return arity; }

    public String render(List<String> values) {
        if (values.size() != arity) {
            throw new TemplateArityException(
                "template expects " + arity + " values, got " + values.size());
        }
        // StringBuilder + indexOf. No regex, no String.replace loop.
        StringBuilder out = new StringBuilder(pattern.length() + 64);
        int cursor = 0;
        for (String value : values) {
            int idx = pattern.indexOf(TOKEN, cursor);
            out.append(pattern, cursor, idx);
            out.append(value == null ? "" : value);
            cursor = idx + TOKEN.length();
        }
        out.append(pattern, cursor, pattern.length());
        return out.toString();
    }
}

Why not a loop of String.replaceFirst? Because replaceFirst takes a regex and a replacement string, and both are interpreted. Why not String.replace, which is literal on both sides? Because it replaces every occurrence at once, so a template with three variables gets the first value in all three slots. Why not build a regex with a Matcher and appendReplacement? See the next section.

Validate before you send, not after. The validateTemplate endpoint takes senderid, msg and dltTemplateId and returns either a success with the punctuation-normalised template or {"status":"error","statusCode":"188","reason":"message template mismatch"}. Status code 188 is the one to wire an alert to. Run this over your full template inventory as a CI gate, so that a copy edit to a message body fails a build rather than failing in production at two in the morning.

public boolean validates(String senderId, String rendered, String dltTemplateId) {
    MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
    applyAuth(form);
    form.add("senderid", senderId);
    form.add("msg", rendered);
    form.add("dltTemplateId", dltTemplateId);
    form.add("output", "json");

    String raw = webClient.post().uri("/SMSApi/validateTemplate")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters.fromFormData(form))
            .exchangeToMono(r -> r.bodyToMono(String.class).defaultIfEmpty(""))
            .block();

    SubmitResult res = parse(raw);
    if ("188".equals(res.statusCode())) {
        log.error("DLT template mismatch for {}: {}", dltTemplateId, res.detail());
        return false;
    }
    return res.accepted();
}

The appendReplacement trap

This is the Java analogue of the JavaScript $& hazard, and it is worse, because Java has two metacharacters in a replacement string rather than one.

In Matcher.appendReplacement(StringBuffer, String) and in String.replaceAll(String, String), the replacement argument is not literal text. A $ followed by a digit is a capture group reference. A ${name} is a named group reference. A backslash escapes. So:

// Looks correct. Is a defect.
String out = template.replaceAll("\\{#var#\\}", customerName);

If customerName is A$1 Motors, $1 is interpreted as a reference to capture group 1. The pattern has no group 1, so this throws IndexOutOfBoundsException at runtime, from an input that arrived from a customer record you did not control. If the pattern does have a group, worse: it silently substitutes the wrong text, the rendered message no longer matches the registered template, and the send is rejected for template mismatch with an error that points at DLT rather than at your string handling.

Real values that contain a dollar sign are not exotic. Pricing in a promotional message. A product SKU. A company name. A URL with a query string that a shortener produced.

If you must use a matcher, quote the replacement:

Matcher m = TOKEN_PATTERN.matcher(template);
StringBuilder sb = new StringBuilder();
Iterator<String> it = values.iterator();
while (m.find()) {
    // Matcher.quoteReplacement neutralises $ and \ in the replacement.
    m.appendReplacement(sb, Matcher.quoteReplacement(it.next()));
}
m.appendTail(sb);

Matcher.quoteReplacement exists precisely for this and is almost never used, because the failure is input-dependent and therefore absent from every test written by the person who wrote the substitution. The StringBuilder and indexOf implementation in the previous section avoids the whole class of problem by never entering the regex engine, which is why it is the one to ship.

While on the subject of character corruption: a smart-quote apostrophe (U+2019), an en dash (U+2013), an ellipsis (U+2026) or a rupee sign (U+20B9) arriving from a content management system or an office document will not match a template registered with the ASCII equivalents, and will additionally flip the message into Unicode encoding and roughly double the cost. Normalise on the way in, and assert on it in a unit test.


Spring Retry, configured not to retry

Spring makes retry a single annotation, which is why Spring applications tend to have the worst duplicate-message problems in this series.

// Do not do this on a send path.
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public SubmitResult send(SendRequest req) { ... }

That annotation retries on any exception by default. ReadTimeoutException is an exception. So the first thing this configuration does, on the first upstream slowdown, is send the message again, to a platform that has already accepted and billed the first one.

The correct posture is asymmetric. Retry read endpoints freely. Never retry a write on an ambiguous failure. Retry a write only on a provably-not-submitted failure.

@Bean
RetryTemplate readRetryTemplate() {
    return RetryTemplate.builder()
            .maxAttempts(4)
            .exponentialBackoff(500, 2.0, 8_000)
            .retryOn(List.of(WebClientRequestException.class, IOException.class))
            .withListener(new MetricsRetryListener())
            .build();
}

@Bean
RetryTemplate sendRetryTemplate() {
    return RetryTemplate.builder()
            .maxAttempts(3)
            .exponentialBackoff(1_000, 2.0, 10_000)
            // Retry ONLY the outcomes we can prove did not reach the platform.
            .retryOn(NotSubmittedException.class)
            .build();
}

NotSubmittedException is thrown only from the NOT_SUBMITTED branch of the classifier defined earlier. Nothing else can enter the send retry template. If a new exception type appears, it is classified UNKNOWN, it is not a NotSubmittedException, and the retry template will not touch it. That is the property you want: the failure mode of an unrecognised error is to stop, not to repeat.

Add full jitter to the backoff if you are retrying at any scale. Spring Retry’s ExponentialBackOffPolicy supports a random multiplier via ExponentialRandomBackOffPolicy; use it, because synchronised retry across a fleet turns a brief upstream wobble into a self-inflicted thundering herd. The strategy behind these choices is set out in SMS API Retry Strategy: Handling Failed Messages.

A note on @Async and @Retryable together: Spring proxies are not applied to self-invocation. If your service method calls another method on the same bean, neither annotation fires, and you will have a retry configuration that appears correct and does nothing. Inject the bean into itself or split the class.


Claim before send

Illustration about Claim before send

Everything above is defence. This is the structure that makes the defence work: the outbound message row is claimed in the database, in a transaction, before the HTTP call happens, and the claim is protected by a unique constraint.

CREATE TABLE outbound_message (
    id              BIGSERIAL PRIMARY KEY,
    business_key    TEXT NOT NULL,
    channel         TEXT NOT NULL,
    recipient       TEXT NOT NULL,
    body            TEXT NOT NULL,
    dlt_template_id TEXT,
    state           TEXT NOT NULL DEFAULT 'PENDING',
    attempt         INT  NOT NULL DEFAULT 0,
    provider_id     TEXT,
    status_code     TEXT,
    units           INT,
    encoding        TEXT,
    cost            NUMERIC(12,4),
    claimed_at      TIMESTAMPTZ,
    submitted_at    TIMESTAMPTZ,
    delivered_at    TIMESTAMPTZ,
    last_error      TEXT,
    CONSTRAINT uq_outbound_business_key UNIQUE (business_key)
);

CREATE INDEX ix_outbound_claim
    ON outbound_message (state, id) WHERE state = 'PENDING';

CREATE INDEX ix_outbound_unknown
    ON outbound_message (state, submitted_at) WHERE state = 'UNKNOWN';

business_key is deterministic and derived from the business event, not from the attempt. Something like order-shipped:{orderId}:{recipient}. It deliberately excludes the channel, so that a fallback from SMS to WhatsApp for the same event cannot produce two messages to the same person. The unique constraint is the actual guarantee; the application-level check is an optimisation.

The Java side:

@Service
public class OutboundSender {

    private final OutboundRepository repo;
    private final SgcSmsClient client;
    private final TransactionTemplate tx;

    @Transactional
    public Optional<Long> enqueue(String businessKey, String recipient, String body, String dltTemplateId) {
        try {
            return Optional.of(repo.insertPending(businessKey, recipient, body, dltTemplateId));
        } catch (DataIntegrityViolationException dup) {
            // Someone already enqueued this business event. Not an error.
            return Optional.empty();
        }
    }

    public void processOne(long id) {
        // 1. Claim in its own short transaction. The HTTP call is NOT inside it.
        OutboundMessage msg = tx.execute(status -> repo.claim(id));
        if (msg == null) {
            return; // already claimed by another worker
        }

        SubmitResult result;
        try {
            result = client.send(msg.toRequest());
        } catch (Throwable t) {
            SendOutcome outcome = SendOutcome.classify(t);
            if (outcome == SendOutcome.NOT_SUBMITTED) {
                // Provably nothing happened. Returning to PENDING is legal here
                // and ONLY here.
                tx.executeWithoutResult(s -> repo.releaseToPending(id, t.toString()));
            } else {
                // It may have been accepted and billed. This row is now the
                // reconciler's problem, forever, or until a DLR resolves it.
                tx.executeWithoutResult(s -> repo.markUnknown(id, t.toString()));
            }
            return;
        }

        if (result.accepted()) {
            tx.executeWithoutResult(s -> repo.markSubmitted(id, result));
        } else {
            tx.executeWithoutResult(s -> repo.markRejected(id, result));
        }
    }
}

Two Java-specific hazards to name explicitly.

Do not put the HTTP call inside @Transactional. A fifteen-second response timeout inside a transaction is a fifteen-second database connection held open, and at a hundred concurrent sends that is a hundred connections held by a pool that probably has thirty. The claim commits, then the call happens, then a second short transaction records the outcome. Three transactions, none of them holding a connection across a network round trip.

catch (Throwable t) is correct here. This is one of the rare places where catching Throwable rather than Exception is the right call, because an Error such as OutOfMemoryError or a NoClassDefFoundError thrown during the send still leaves the row in a state where you do not know what happened, and leaving it CLAIMED forever is worse than recording UNKNOWN. Record and rethrow if you prefer, but do not let it escape without writing the state.

releaseToPending is called from exactly one branch. That constraint is the whole design. An UNKNOWN row never returns to PENDING, because every trip back to PENDING is a licence to send again.

The reconciler is a scheduled job that takes UNKNOWN rows older than a few minutes and asks the platform what actually happened, using the by-transaction status endpoint when a provider_id was captured, or the delivery report endpoint filtered by recipient and time window when it was not. Alert on the age of the oldest unreconciled row, not on the count. Age tells you whether you are approaching the point where the delivery report window closes and the question becomes permanently unanswerable.


Batching and the recipient list

The send endpoint accepts a comma-separated recipient list, and the same request can carry a JSON or XML body with an sms array of {mobile:[...], msg:"..."} objects, which allows a different body per recipient in one call.

The arithmetic that catches people out: one request with five hundred recipients and a three-segment message is fifteen hundred billable units, not one. Rate limiting a messaging integration by requests per second is measuring the wrong thing entirely. The cost unit is recipients multiplied by segments.

public List<SubmitResult> sendBatched(List<String> recipients, String body) {
    int perRequest = props.maxRecipientsPerRequest();
    List<SubmitResult> out = new ArrayList<>();
    for (int i = 0; i < recipients.size(); i += perRequest) {
        List<String> slice = recipients.subList(i, Math.min(i + perRequest, recipients.size()));
        out.add(send(SendRequest.of(slice, body)));
    }
    return out;
}

The critical caveat, and it is the reason the batch shape is not free: there is one top-level transactionId per request, not one per recipient. A five-hundred-recipient request returns a single identifier. Per-recipient correlation has to come afterwards from the delivery report endpoint, matched on mobileNo, msgId and uuId. If your outbound table has one row per recipient and you store the same transactionId on all five hundred of them, your reconciler cannot distinguish them and your billing dispute has no evidence.

The practical resolution: keep one row per recipient, store the shared transactionId as a batch identifier in a separate column, and populate the per-recipient msgId during reconciliation. Do not overload one column with both meanings.


OTP in Spring Boot

OTP has its own endpoint and its own semantics. Generate and verify both POST to https://unify.smsgateway.center/SMSApi/otp, distinguished by sendMethod.

Generate takes sendMethod=generate, mobile, msgType, a msg containing the literal $otp$ placeholder, medium=sms, codeType of num, alpha or alphanum, codeExpiry in seconds with a default of 300, codeLength, senderid and output. Success returns statusCode 300, along with createTime, expiryTime and retryAfter. Verify takes sendMethod=verify, mobile, otp and output, and success returns statusCode 301.

The Java-relevant detail: createTime, expiryTime and retryAfter are millisecond epochs, not durations and not ISO strings. Convert them at the boundary and never let a raw long escape into your domain.

public record OtpChallenge(String mobile, Instant createdAt, Instant expiresAt, Instant retryAfter) {

    static OtpChallenge from(String mobile, JsonNode n) {
        return new OtpChallenge(
                mobile,
                instant(n, "createTime"),
                instant(n, "expiryTime"),
                instant(n, "retryAfter"));
    }

    private static Instant instant(JsonNode n, String field) {
        JsonNode v = n.get(field);
        if (v == null || v.isNull()) return null;
        // Documented as a millisecond epoch. Guard against a string form.
        long millis = v.isNumber() ? v.asLong() : Long.parseLong(v.asText().trim());
        return Instant.ofEpochMilli(millis);
    }

    public Duration retryIn(Clock clock) {
        return retryAfter == null ? Duration.ZERO
                : Duration.between(clock.instant(), retryAfter).isNegative()
                    ? Duration.ZERO
                    : Duration.between(clock.instant(), retryAfter);
    }
}

retryIn(Clock) takes a Clock rather than calling Instant.now(). This is not ceremony. Expiry logic is untestable without an injectable clock, and expiry logic that is untested is expiry logic that is wrong. Inject Clock.systemUTC() as a bean, use Clock.fixed in tests, and you can assert the boundary behaviour at the exact millisecond.

OTP inverts the usual backpressure policy. For an ordinary campaign message, if you are over capacity you buffer and send it a minute later. For an OTP, a message that arrives a minute late is worse than useless: the user has already pressed resend, so you have now paid for two messages and delivered a confusing experience. Shed OTP load, do not buffer it. Return an error to the user immediately and let them retry a request that will actually be fast.

Also design the resend policy against the cost, not against the UX alone. If your interface offers a resend button after fifteen seconds and the platform’s retryAfter is longer, every impatient user generates a rejected request; if your button is available sooner than the message typically arrives, every impatient user generates a second billed send. Set the button’s cooldown from retryAfter, not from a hardcoded constant.


WhatsApp from the same codebase

The WhatsApp send endpoint is POST https://unify.smsgateway.center/WAApi/send, POST only, application/x-www-form-urlencoded, with required userid, password, msg, wabaNumber, mobile in international format, sendMethod, msgType of text or media, templateName and output. Optional parameters include header, footer, scheduletime in YYYY-MM-DD HH:MM form, buttonsPayload as JSON, and buttonUrlPath. For media, mediaId or mediaUrl, mediaType and documentName.

The documented behaviour worth building around: “If you are sending Session based message, then send open message and remove templateName parameter. If its in session then the message will get delivered.” In other words, the presence or absence of templateName is the switch between a template message and a free-form session message. In Java, model that as a sealed interface rather than a nullable string, so the compiler enforces that you decided.

public sealed interface WhatsAppBody permits TemplateBody, SessionBody {}
public record TemplateBody(String templateName, String rendered) implements WhatsAppBody {}
public record SessionBody(String rendered) implements WhatsAppBody {}

Note the format differences you must normalise. scheduletime is lowercase t here and YYYY-MM-DD HH:MM without seconds, whereas the SMS scheduleTime is camelCase and carries seconds. That is exactly the kind of difference a DateTimeFormatter constant per channel handles and a shared one does not.

The channel-selection question, meaning when SMS is the right answer and when WhatsApp is, is a separate decision covered in SMS API vs WhatsApp Business API. If you are chaining channels, the multi-channel fallback product supports narrowing the trigger to specific delivery error codes rather than falling through on any failure.


Delivery reports: the webhook receiver

Register a webhook by POSTing to https://unify.smsgateway.center/SMSApi/webhook/create with smswebhook set to your HTTPS URL and smswebhookrate, documented as “DLR TPS. AT what TPS rate DLR should be forwarded to. Default is 10.” That parameter is your inbound backpressure control, and it is listed as required. Set it to a rate your receiver can actually sustain, because the alternative to slowing the sender down is dropping receipts.

Setup and panel configuration are covered in Real-Time DLR Webhooks and the webhook setup knowledge base entry. The Java receiver is below.

@RestController
@RequestMapping("/webhooks/dlr")
public class DlrController {

    private final byte[] secret;
    private final DlrIngestService ingest;

    @PostMapping
    public ResponseEntity<Void> receive(
            @RequestBody byte[] raw,                     // RAW BYTES. Not a POJO.
            @RequestHeader(value = "X-Signature", required = false) String signature) {

        if (secret.length > 0) {
            if (signature == null || !verify(raw, signature)) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
            }
        }

        // Persist first, parse later, acknowledge fast.
        ingest.stageRaw(raw);
        return ResponseEntity.ok().build();
    }

    private boolean verify(byte[] raw, String signature) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            byte[] expected = mac.doFinal(raw);
            byte[] provided = HexFormat.of().parseHex(signature.trim());
            // Constant time. Arrays.equals is not.
            return MessageDigest.isEqual(expected, provided);
        } catch (Exception e) {
            return false;
        }
    }
}

Four things here are load-bearing.

@RequestBody byte[] rather than a mapped POJO. A signature is computed over the exact bytes on the wire. If Spring deserializes into an object and you re-serialize to verify, whitespace, key order and numeric formatting will differ and the signature will never match. Take the bytes.

MessageDigest.isEqual rather than Arrays.equals or String.equals. It is the constant-time comparison in the JDK. A timing side channel on a webhook signature is a real, if unglamorous, vulnerability.

Persist before parse. Write the raw payload to a staging table and return 200 immediately. Parsing in the request thread means a schema surprise produces a non-200, which produces a retry from the sender, which produces load at exactly the moment you are broken. Acknowledge, then process asynchronously.

A configured content-type filter can eat the body. If the payload arrives application/x-www-form-urlencoded and your application has a global JSON converter or a filter that consumes the input stream, raw arrives empty and your controller happily returns 200 for a receipt it never saw. Assert on payload length in the receiver and count zero-length bodies as a metric.

The outbound push payload schema is not publicly documented. This article does not name its fields, because guessing them would be worse than useless. Capture the first real payloads into your staging table, derive the schema from what actually arrives, and pin it with a contract test. That is the honest path, and it is why the receiver above stages raw bytes rather than binding a class.


Delivery reports: the poller

The webhook is not sufficient on its own, because a receiver that was down for twenty minutes has a hole in it. Run a poller as well, against https://unify.smsgateway.center/SMSApi/reports/status with method=getDlr, fromdate, todate, pageLimit, and optionally mobileNo, uuId or startCursor. Each row in reports_dlrList carries msgId, mobileNo, uuId, status, cause, cost, length, submitTime, deliveryTime, dltTemplateId, globalErrorCode, network, senderName and cursorId.

This is a read endpoint, so it is safe to retry at the transport level, unlike the send path. Use the read retry template.

@Component
public class DlrPoller {

    private final RetryTemplate readRetry;

    public void pollWindow(LocalDate from, LocalDate to) {
        String cursor = null;
        do {
            final String c = cursor;
            DlrPage page = readRetry.execute(ctx -> fetchPage(from, to, c, 500));
            for (DlrRow row : page.rows()) {
                // BigDecimal, never double. This value ends up in a cost report.
                BigDecimal cost = new BigDecimal(
                        row.cost() == null || row.cost().isBlank() ? "0" : row.cost());
                reconcile(row, cost);
            }
            cursor = page.nextCursor();
        } while (cursor != null);
    }
}

The cursor is what makes this consumer-paced rather than provider-paced: you pull the next page when you have finished the previous one, so a slow database on your side slows the pull rather than filling a buffer. That is backpressure by construction, and it is the same property the async generator gives you in the Python and Node.js versions.

Two reconciliation notes. Match on three filters, not one: recipient, plus a submit-time window, plus message identity. Matching on recipient alone will attach the wrong receipt when the same person received two messages in the same minute, which for an OTP resend is the common case rather than the rare one. And discard negative latency: a deliveryTime earlier than a submitTime is clock skew between systems, and recording it as a negative duration poisons a latency histogram permanently.

If you want the reporting terminology straight first, the knowledge base distinguishes the delivery report from the delivery summary and explains what the DLR statuses mean.


Resilience4j: rate limiter, bulkhead and circuit breaker

Three mechanisms, commonly confused, doing three different jobs.

MechanismConstrainsUse it for
Rate limiterOperations per unit timeStaying under an account throughput allowance
BulkheadConcurrent operations in flightProtecting your own connection and thread pools
Circuit breakerCalls after a failure thresholdStopping a doomed call quickly, with care
resilience4j:
  ratelimiter:
    instances:
      sgcSend:
        limitForPeriod: 20
        limitRefreshPeriod: 1s
        timeoutDuration: 2s      # how long a caller waits for a permit
        registerHealthIndicator: true
  bulkhead:
    instances:
      sgcSend:
        maxConcurrentCalls: 25
        maxWaitDuration: 1s
  circuitbreaker:
    instances:
      sgcRead:                   # NOTE: read endpoints only
        slidingWindowSize: 50
        failureRateThreshold: 60
        waitDurationInOpenState: 30s
@RateLimiter(name = "sgcSend")
@Bulkhead(name = "sgcSend", type = Bulkhead.Type.SEMAPHORE)
public SubmitResult sendGoverned(SendRequest req) {
    return client.send(req);
}

Three warnings, all of which have bitten production systems.

The Resilience4j rate limiter is per JVM. It is an in-process semaphore. Run four instances of your service and you have four times the configured rate. This is the same trap as Celery’s rate_limit being per worker. If your account allowance is a fleet-wide number, either divide the limit by the instance count and accept the waste, or implement a shared limiter in Redis. Do not assume a local limiter enforces a global constraint.

Do not put a circuit breaker on the send path without thinking. The breaker’s job is to fail fast. On a read that is a clean win. On a send, “fail fast” means a queue of billable work backing up while the breaker is open, and then a flood when it half-opens. If you use one on sends, pair it with an explicit shed-or-buffer policy per message class, and make sure the buffer has a bound. An unbounded buffer behind an open breaker is a memory leak with a schedule.

The limiter must be outside the retry, not inside it. If a retry sits outside a rate limiter, each attempt consumes a permit, which is correct. If the limiter sits outside the retry, one permit buys three attempts, and your effective rate is three times what you configured. Order the annotations deliberately; Resilience4j‘s default aspect order applies Bulkhead, then RateLimiter, then CircuitBreaker, then Retry, and you can override it with resilience4j.*.aspect-order.

For the provider-side view of how throttling works and what the platform does with excess traffic, see API Rate Limiting and Throttling: Best Practices for SMS Services. If you are on SMPP rather than HTTP, throughput is a window-size problem instead and is covered in SMPP Rate Limits and Throughput Tuning.

There is no publicly documented TPS ceiling on the HTTP API. Rather than guess one, discover yours: start conservative, increase the limiter’s limitForPeriod gradually while watching the rejection rate and latency, and back off multiplicatively the moment either moves. Record the number you land on in your own configuration and treat it as an account fact, not a platform fact.


Micrometer instrumentation

Instrument at submission and at delivery separately. They are different clocks. Submission is synchronous, fast, and the moment you are billed. Delivery is asynchronous, takes minutes to hours, and is the only thing the recipient experiences. A dashboard that shows only submission health will show green through an incident where nothing is arriving.

@Component
public class MessagingMetrics {

    private final MeterRegistry registry;
    private final Timer submitTimer;

    public MessagingMetrics(MeterRegistry registry) {
        this.registry = registry;
        this.submitTimer = Timer.builder("messaging.submit.duration")
                .publishPercentileHistogram()
                // Buckets must STRADDLE the response timeout. Default buckets
                // top out well below 15s and give zero resolution at the cliff.
                .minimumExpectedValue(Duration.ofMillis(50))
                .maximumExpectedValue(Duration.ofSeconds(30))
                .register(registry);
    }

    public void recordSubmit(Duration d, String channel, String outcome, String statusCode) {
        submitTimer.record(d);
        registry.counter("messaging.submit.total",
                "channel", channel,
                "outcome", outcome,
                "status_code", statusCode).increment();
    }

    /** Billable volume is recipients x segments, not requests. */
    public void recordUnits(int recipients, int segments, String encoding, String channel) {
        registry.counter("messaging.units.total",
                "encoding", encoding,
                "channel", channel).increment((double) recipients * segments);
    }
}

The cardinality rule, stated plainly because it is the mistake that takes monitoring down during the incident you most need it: never use mobileNo, transactionId, msgId or message text as a metric tag. One two-hundred-thousand-recipient campaign with a recipient tag creates two hundred thousand time series. Tag by channel, encoding, message class, status code and DLT template ID, all of which are low cardinality. Put the identifiers in structured logs, where high cardinality is the point.

Four gauges worth more than any counter, in rough order of predictive value:

  1. Age of the oldest pending outbound row. It already encodes depth, drain rate and stalls in one number, and its unit is user patience, which makes it the one metric where an absolute threshold is defensible.
  2. Drain rate versus arrival rate. A sustained inversion is a queue that will not recover on its own.
  3. Ambiguous outcome rate, meaning the proportion of sends classified UNKNOWN. This rises before the error rate does, because a platform under strain gets slow before it starts refusing.
  4. Cost per delivered message, computed from the real cost field on the delivery report rather than from a blended rate. A rising value means retries or encoding drift.

Add a fifth if you are running a wallet account: project balance runway from the account status and alert against your procurement lead time. Balance exhaustion does not present as a helpful message, it presents as an unfamiliar error code at three in the morning. The Dashboard Analytics endpoint returns balanceRaw and averagePerDay, which is all the arithmetic requires.

Finally, alert on ratios rather than absolutes almost everywhere, and compute the delivery ratio against submitted, not against delivered plus failed. If delivery receipts stop arriving entirely, delivered and failed both freeze, and their ratio freezes at a healthy-looking constant. Pending over submitted is the alarm that catches a dead receipt pipeline.


Testing with JUnit 5 and MockWebServer

The reframe that reorders the whole test pyramid: billing fires at submission, not at delivery. The published billing terms state that “credits are non-refundable once SMS is successfully submitted to the operator” and that the per-SMS rate is deducted from the wallet while sending. So a test suite that really sends is a test suite that really bills, on every commit, forever. That is a financial argument, not an aesthetic one, and it is why almost everything belongs below the integration layer.

LayerWhat it coversCostFrequency
Pure functionsSegment counting, DLT rendering, epoch conversionZeroEvery commit
Transport mocksClient behaviour, timeouts, parsing, classificationZeroEvery commit
Contract checksError code lists, response shapesZero, read-only endpointsNightly
Live smokeCredentials work, a real message arrivesReal moneyPre-release only

Mock at the transport, not at your own class. A test that mocks SgcSmsClient and asserts it was called proves that your code called your code. It cannot catch a wrong parameter name, a wrong content type, or a misparsed response, which is the entire population of bugs this integration actually has. Use MockWebServer or WireMock and let the real client make a real HTTP request to a fake server.

class SgcSmsClientTest {

    private MockWebServer server;
    private SgcSmsClient client;

    @BeforeEach
    void setUp() throws IOException {
        server = new MockWebServer();
        server.start();
        client = buildClientPointingAt(server.url("/").toString(), Duration.ofSeconds(2));
    }

    @AfterEach
    void tearDown() throws IOException { server.shutdown(); }

    @Test
    void sendsFormEncodedBodyWithCommaSeparatedRecipients() throws Exception {
        server.enqueue(new MockResponse()
                .setBody("{\"status\":\"success\",\"statusCode\":\"200\"," +
                         "\"transactionId\":\"123\",\"reason\":\"success\",\"invalidMobile\":\"\"}")
                .addHeader("Content-Type", "application/json"));

        client.send(SendRequest.of(List.of("919999999999", "919999999998"), "Hello"));

        RecordedRequest req = server.takeRequest();
        assertThat(req.getHeader("Content-Type"))
                .startsWith("application/x-www-form-urlencoded");
        String body = req.getBody().readUtf8();
        assertThat(body).contains("mobile=919999999999%2C919999999998");
        assertThat(body).contains("sendMethod=quick");
        assertThat(body).doesNotContain("password=");   // apiKey mode
    }

    @Test
    void readTimeoutIsUnknownAndIsAttemptedExactlyOnce() {
        server.enqueue(new MockResponse()
                .setBodyDelay(5, TimeUnit.SECONDS)      // longer than the 2s timeout
                .setBody("{}"));

        Throwable t = catchThrowable(() ->
                client.send(SendRequest.of(List.of("919999999999"), "Hello")));

        assertThat(SendOutcome.classify(t)).isEqualTo(SendOutcome.UNKNOWN);
        // The assertion that actually matters: we did not send it twice.
        assertThat(server.getRequestCount()).isEqualTo(1);
    }

    @Test
    void acceptsQuotedAndUnquotedStatusCode() {
        // SMSApi/send quotes it, WAApi/send does not. Both must parse.
        assertThat(parse("{\"status\":\"success\",\"statusCode\":\"200\"}").statusCode())
                .isEqualTo("200");
        assertThat(parse("{\"status\":\"success\",\"statusCode\":200}").statusCode())
                .isEqualTo("200");
    }

    @ParameterizedTest
    @CsvSource({
        "160, TEXT, 1", "161, TEXT, 2", "306, TEXT, 2", "307, TEXT, 3",
        "70,  UNICODE, 1", "71, UNICODE, 2"
    })
    void segmentBoundaries(int length, String encoding, int expectedSegments) { /* ... */ }
}

assertThat(server.getRequestCount()).isEqualTo(1) in the timeout test is the single most valuable assertion in the suite. It is the executable statement of the rule that an ambiguous outcome is not retried, and it will fail loudly the day someone adds @Retryable to the send method.

Two more tests worth writing. A concurrency test that starts twenty threads on a CyclicBarrier, all attempting to enqueue the same business_key against a real database, and asserts exactly one row exists. And a webhook test that posts a form-encoded body to the DLR controller and asserts the received byte array is non-empty, which catches the global-converter bug described earlier.

For nightly contract checks, diff the codes your application handles against the live API response error code list and delivery error code list. Both are read-only and free, which makes them the best contract-testing surface available. Default any unrecognised code to permanent rather than transient. Defaulting to transient means an unknown code produces an infinite retry loop, and every iteration of that loop is billed.

An account-level Sandbox exists, documented on the demo page: “The demo environment is designed for testing. Some features use a Sandbox mode, where delivery is simulated for security.” What the documentation does not say is which features are simulated, whether sandbox credentials differ from live, or whether responses are byte-identical. Determine those on your own account before relying on them.

There is also testMessage, documented verbatim as “Enable true to test your message and messages wont be delivered when enabled true.” Note what that sentence does not say: nothing about whether credits are consumed, nothing about whether a delivery receipt is generated, nothing about whether the response shape differs. Determine the credit behaviour empirically on your own account before wiring it into a load test: read your balance, send one message with testMessage=true, read the balance again, and record the answer in your own runbook.


Security and secret handling

  • Send credentials over TLS only, and keep your trust store current. If you hit handshake failures, the TLS 1.3 upgrade guide covers the platform side.
  • Prefer the apiKey header over password in the form body. It keeps the credential out of anything that logs request bodies, which in a default Spring Boot application with WebClient logging enabled at DEBUG is quite a lot.
  • Override toString on any record or class holding a credential, as shown earlier.
  • Redact at serialisation, not at each call site. A logging filter or a Logback converter that masks anything matching your credential patterns is the only approach that survives a new developer adding a log line.
  • Hash recipient numbers with a salt before putting them in logs, rather than truncating them. Truncation is reversible against a customer database; a salted hash still supports joining logs for one recipient without storing the number.
  • Never expose the OAuth client_secret in a mobile app package or in browser JavaScript. The documented flow requires the token exchange to happen server-side.
  • Validate state on every OAuth callback. Spring Security does this for you; if you hand-roll the callback, do it yourself.
  • Rotate API keys on a schedule and support two valid keys during the overlap, or rotation becomes an outage nobody wants to schedule.

The ten-step build order

  1. Create an API key, and send one message with cURL before writing any Java. Confirm the response shape with your own eyes.
  2. Add the WebClient bean with an explicit connect timeout and response timeout. Verify by pointing it at a deliberately slow endpoint.
  3. Write the SendOutcome classifier and its tests before you write the send client. The classifier is the design.
  4. Write the send client with a MultiValueMap body and raw-body capture. Test it against MockWebServer.
  5. Add the outbound_message table with the unique constraint on business_key. Prove the constraint with a concurrency test.
  6. Wire claim-before-send, with the HTTP call outside the transaction.
  7. Add the segment counter and the DLT renderer, with boundary tests and a dollar-sign test.
  8. Add validateTemplate as a CI gate over your template inventory.
  9. Add the DLR webhook receiver staging raw bytes, plus the poller as a backstop. Reconcile UNKNOWN rows.
  10. Add Micrometer metrics and alerts, starting with age of the oldest pending row. Then, and only then, turn up the rate limiter.

Step 3 before step 4 is not a stylistic preference. If you write the client first you will write it to return a value on success and throw on failure, and retrofitting a three-valued outcome into that shape is a rewrite.


Decision matrix

SituationChoiceWhy
Single-tenant service sending on its own accountAPI key in a headerSimplest, keeps the secret out of the body
Product sending on behalf of many customer accountsOAuth 2.0 with PKCENo API key pasting, revocable per customer
Blocking service, no reactive stackWebClient with .block()Centralised timeout configuration
Existing RestTemplate codebaseKeep it, but move to HttpComponentsClientHttpRequestFactoryTwo explicit timeouts instead of two infinities
High volume, same body to many recipientsComma-separated batchFewer round trips, but one transactionId for all
Different body per recipientJSON sms array on the same endpointAvoids one request per recipient
Transient upstream failure on a readSpring Retry with backoff and jitterReads are idempotent
Ambiguous failure on a sendNo retry, state UNKNOWN, reconcileA retry here is a second charge
Over capacity, campaign trafficBufferLate is acceptable
Over capacity, OTP trafficShedLate guarantees a user resend and a second charge
Need per-recipient delivery statusPoll the report endpointThe submit response carries one identifier for the batch
Need a nightly correctness signalDiff the two error code listsFree, read-only, catches contract drift

Production checklist

HTTP layer

  • [ ] Connect timeout set explicitly
  • [ ] Response timeout set explicitly and lower than any caller timeout
  • [ ] pendingAcquireTimeout set on the connection provider
  • [ ] maxInMemorySize raised above the default for report responses
  • [ ] Content type is application/x-www-form-urlencoded on every write
  • [ ] Values are not pre-encoded before fromFormData
  • [ ] Raw response body captured and stored on every send

Parsing

  • [ ] FAIL_ON_UNKNOWN_PROPERTIES disabled
  • [ ] statusCode accepts quoted and unquoted forms
  • [ ] transactionId and messageId normalised to one field
  • [ ] reason and description normalised to one field
  • [ ] @JsonProperty on every field of the by-transaction report row
  • [ ] cost parsed as BigDecimal
  • [ ] invalidMobile inspected on every multi-recipient send

Correctness

  • [ ] Segment counter iterates code points in the GSM branch
  • [ ] Segment counter uses code units in the Unicode branch
  • [ ] Boundary tests at 160, 161, 306, 307, 70 and 71
  • [ ] DLT renderer is positional and does not use the regex engine
  • [ ] Test with a value containing $1, $& and a backslash
  • [ ] Smart quotes, en dashes, ellipses and rupee signs normalised on input
  • [ ] validateTemplate runs in CI over every template

Idempotency and state

  • [ ] Unique constraint on business_key
  • [ ] business_key excludes the channel
  • [ ] HTTP call is outside the transaction
  • [ ] UNKNOWN never transitions back to PENDING
  • [ ] Reconciler runs on a schedule and alerts on oldest unreconciled age
  • [ ] One row per recipient, batch identifier in its own column

Resilience

  • [ ] Send retry template accepts only NotSubmittedException
  • [ ] Read retry template is separate and more permissive
  • [ ] Full jitter on backoff
  • [ ] Rate limiter understood to be per JVM
  • [ ] Bulkhead sized below the connection pool
  • [ ] No circuit breaker on the send path without a bounded buffer
  • [ ] Aspect order verified so the limiter is outside the retry

Inbound

  • [ ] smswebhookrate set to a rate the receiver sustains
  • [ ] Webhook body received as byte[]
  • [ ] Signature compared with MessageDigest.isEqual
  • [ ] Persist before parse, acknowledge fast
  • [ ] Zero-length body counted as a metric
  • [ ] Poller runs as a backstop with cursor pagination
  • [ ] Negative delivery latency discarded, not recorded

Observability

  • [ ] Submission and delivery metrics on separate axes
  • [ ] Histogram buckets straddle the response timeout
  • [ ] Volume counter increments by recipients times segments
  • [ ] No recipient or transaction identifiers as metric tags
  • [ ] Delivery ratio denominator is submitted
  • [ ] Age of oldest pending row alerted
  • [ ] Balance runway alerted against procurement lead time

Security

  • [ ] toString overridden on credential-bearing types
  • [ ] Redaction implemented as a serialisation filter
  • [ ] Recipient numbers salted and hashed in logs
  • [ ] OAuth client secret server-side only
  • [ ] Key rotation supports an overlap window

Ten mistakes

  1. Leaving the response timeout unset. The most common Spring Boot messaging defect. One upstream stall saturates a bounded pool and takes the whole application with it.
  2. Adding @Retryable to the send method. One annotation, duplicate messages, duplicate charges, and a customer complaint that arrives before your alert does.
  3. Posting JSON to the quick-send path. Presents as an authentication error. Costs an afternoon and sometimes a credential rotation that was never needed.
  4. Using String.replaceAll for DLT variables. Breaks on any value containing a dollar sign, and breaks in a way that reads as a DLT problem.
  5. Counting segments with length(). Overcounts in the GSM branch for extension characters and misclassifies encoding, so your cost model disagrees with the invoice.
  6. Treating status: success as all recipients accepted. invalidMobile enumerates the rejected ones while the top-level status stays green.
  7. Storing one transactionId on five hundred recipient rows. Destroys per-recipient reconciliation and leaves a billing dispute with no evidence.
  8. Keeping the HTTP call inside @Transactional. Database connections held across network round trips, pool exhaustion at modest concurrency.
  9. Mocking your own client class in tests. Proves your code calls your code. Catches none of the bugs this integration actually produces.
  10. Assuming the Resilience4j rate limiter is global. Four instances, four times the rate, and a throttling incident that looks like a platform problem.

What this article deliberately does not claim

Every item below is a question a reader will reasonably have, where the honest answer is that the platform does not publish it. Guessing would make this article worse, so these are stated as open questions instead.

  1. There is no numeric TPS ceiling here. The HTTP API does not publish one. Discover yours empirically as described in the rate limiting section and record it as an account fact.
  2. The webhook push payload schema is not named. It is not publicly documented. The receiver in this article stages raw bytes precisely so that you can derive the schema from real traffic.
  3. Whether testMessage=true consumes credits is not stated. The documentation says only that messages will not be delivered. Measure it on your own account.
  4. Whether Sandbox responses are byte-identical to live responses is not stated, nor is the list of which features are simulated, nor whether sandbox credentials differ.
  5. No delivery percentage or SLO target is quoted. Delivery rates depend on route, operator, sender ID, template and time of day, and any single figure would be misleading.
  6. The delivery report retention window length is not stated, which matters because it bounds how long an UNKNOWN row remains resolvable. Confirm it for your account before setting a reconciliation alert threshold.
  7. Rate limits on the reporting and dashboard endpoints are not documented. Poll conservatively and observe.
  8. Whether the apiKey header is case-sensitive is not settled by the docs, which show both apiKey and apikey in different places. Both appear in official samples. Pick one, test it, and file the inconsistency with support.

FAQ

Is there an official Java or Spring Boot SDK? No. The developer documentation lists a PHP SDK and a Python SDK only. Write a thin client, as shown above. The API surface is small enough that a dependency would buy you little and cost you control over the timeout configuration that actually matters.

Should I use RestTemplate or WebClient? WebClient, and call .block() if your application is not reactive. The reason is not performance, it is that WebClient centralises the timeout configuration in one bean where you can verify it, while RestTemplate scatters the same requirement across call sites.

Why does my request fail authentication when the credentials are correct? Almost always because the body was sent as JSON rather than application/x-www-form-urlencoded on the quick-send path. The parameters are never parsed, so userid and password appear absent. Check the content type before rotating anything.

How do I send to multiple recipients in one call? Put a comma-separated list in mobile. The documentation describes “lists of comma-separated mobile numbers” and tolerates a space after the comma. For a different body per recipient, use the documented JSON body with an sms array of {mobile:[...], msg:"..."} objects.

Does duplicatecheck protect me from sending the same message twice on a retry? No. It removes duplicate mobile numbers within a single request and defaults to true. There is no documented cross-request behaviour and no time window. Cross-request protection is your database’s unique constraint, not this parameter.

What should I do when a send times out and I do not know if it went through? Nothing immediately. Record the row as UNKNOWN, never as PENDING, and let a reconciler resolve it against the delivery report. Retrying an ambiguous send is how a single slow minute becomes a duplicate billing incident.

Why does Jackson throw on the WhatsApp response but not the SMS one? Because statusCode is a quoted string on SMSApi/send and an unquoted number on WAApi/send. Bind it with a lenient deserializer that accepts both, and normalise onto one internal type.

How do I map a response field called Channel Name? With an explicit @JsonProperty("Channel Name"). No naming strategy will infer it, because there is no legal Java identifier corresponding to a name with a space in it. The by-transaction report endpoint returns several such fields.

How do I count SMS segments correctly in Java? Iterate by code point when testing GSM-7 membership, and count UTF-16 code units in the Unicode branch. length() alone is wrong in the GSM branch and right in the Unicode branch, and using it in both places produces a counter that disagrees with your invoice.

Why is String.replaceAll unsafe for DLT variable substitution? Because the replacement argument is interpreted, not literal. A $ followed by a digit is a capture group reference, so a customer name or product code containing a dollar sign either throws IndexOutOfBoundsException or substitutes the wrong text and fails template matching. Use StringBuilder and indexOf, or Matcher.quoteReplacement.

Can I use OAuth instead of an API key? Yes. The platform documents an OAuth 2.0 Authorization Code flow with PKCE, with S256 only, an access token lasting one hour and a refresh token lasting thirty days. Register a client through support. Note that you still send userid on every SMS API request, set to the username returned in the token response.

Does the Resilience4j rate limiter apply across all my instances? No. It is an in-process semaphore, so each JVM enforces the configured rate independently. For a fleet-wide constraint, divide the limit by the instance count or implement a shared limiter in Redis.

Why should the HTTP call sit outside the database transaction? Because a fifteen-second response timeout inside a transaction holds a database connection for fifteen seconds. At modest concurrency that exhausts the pool and turns an upstream slowdown into a full application outage.

How do I test this without spending money? Mock at the transport layer with MockWebServer or WireMock, so the real client makes a real HTTP request to a fake server. Keep live sends to a small pre-release smoke test with an environment-driven recipient allowlist. Billing fires at submission, so a suite that really sends really bills on every commit.


Ready to wire this into your Spring Boot service? Create an account and generate an API key, then try the flow end to end with the demo environment before you point it at production traffic. If you are building a product that sends on behalf of other people’s accounts, contact support to register an OAuth client.


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!