{"id":2710,"date":"2026-08-24T11:22:40","date_gmt":"2026-08-24T05:52:40","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2710"},"modified":"2026-08-24T11:22:44","modified_gmt":"2026-08-24T05:52:44","slug":"messaging-java-spring-boot-integration","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-java-spring-boot-integration\/","title":{"rendered":"Messaging in Java and Spring Boot: The Full Integration Guide (2026)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#executive-summary\" data-type=\"internal\" data-id=\"#executive-summary\">Executive summary<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\" data-type=\"internal\" data-id=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#why-java-needs-its-own-guide\" data-type=\"internal\" data-id=\"#why-java-needs-its-own-guide\">Why Java needs its own guide<\/a><\/li>\n\n\n\n<li><a href=\"#is-there-an-official-java-sdk\" data-type=\"internal\" data-id=\"#is-there-an-official-java-sdk\">Is there an official Java SDK<\/a><\/li>\n\n\n\n<li><a href=\"#choosing-the-http-client\" data-type=\"internal\" data-id=\"#choosing-the-http-client\">Choosing the HTTP client<\/a><\/li>\n\n\n\n<li><a href=\"#the-timeout-problem-in-full\" data-type=\"internal\" data-id=\"#the-timeout-problem-in-full\">The timeout problem, in full<\/a><\/li>\n\n\n\n<li><a href=\"#connect-timeout-versus-read-timeout\" data-type=\"internal\" data-id=\"#connect-timeout-versus-read-timeout\">Connect timeout versus read timeout<\/a><\/li>\n\n\n\n<li><a href=\"#form-encoding-and-why-json-looks-like-an-auth-failure\" data-type=\"internal\" data-id=\"#form-encoding-and-why-json-looks-like-an-auth-failure\">Form encoding, and why JSON looks like an auth failure<\/a><\/li>\n\n\n\n<li><a href=\"#jackson-at-the-boundary\" data-type=\"internal\" data-id=\"#jackson-at-the-boundary\">Jackson at the boundary<\/a><\/li>\n\n\n\n<li><a href=\"#the-response-contract-is-not-consistent-and-java-notices-first\" data-type=\"internal\" data-id=\"#the-response-contract-is-not-consistent-and-java-notices-first\">The response contract is not consistent, and Java notices first<\/a><\/li>\n\n\n\n<li><a href=\"#configuration-and-credentials\">Configuration and credentials<\/a><\/li>\n\n\n\n<li><a href=\"#the-send-client\" data-type=\"internal\" data-id=\"#the-send-client\">The send client<\/a><\/li>\n\n\n\n<li><a href=\"#authentication-api-key-and-now-oauth-20-with-pkce\" data-type=\"internal\" data-id=\"#authentication-api-key-and-now-oauth-20-with-pkce\">Authentication: API key, and now OAuth 2.0 with PKCE<\/a><\/li>\n\n\n\n<li><a href=\"#counting-segments-correctly-in-java\" data-type=\"internal\" data-id=\"#counting-segments-correctly-in-java\">Counting segments correctly in Java<\/a><\/li>\n\n\n\n<li><a href=\"#the-india-dlt-layer\" data-type=\"internal\" data-id=\"#the-india-dlt-layer\">The India DLT layer<\/a><\/li>\n\n\n\n<li><a href=\"#the-appendreplacement-trap\" data-type=\"internal\" data-id=\"#the-appendreplacement-trap\">The appendReplacement trap<\/a><\/li>\n\n\n\n<li><a href=\"#spring-retry-configured-not-to-retry\" data-type=\"internal\" data-id=\"#spring-retry-configured-not-to-retry\">Spring Retry, configured not to retry<\/a><\/li>\n\n\n\n<li><a href=\"#claim-before-send\" data-type=\"internal\" data-id=\"#claim-before-send\">Claim before send<\/a><\/li>\n\n\n\n<li><a href=\"#batching-and-the-recipient-list\" data-type=\"internal\" data-id=\"#batching-and-the-recipient-list\">Batching and the recipient list<\/a><\/li>\n\n\n\n<li><a href=\"#otp-in-spring-boot\" data-type=\"internal\" data-id=\"#otp-in-spring-boot\">OTP in Spring Boot<\/a><\/li>\n\n\n\n<li><a href=\"#whatsapp-from-the-same-codebase\" data-type=\"internal\" data-id=\"#whatsapp-from-the-same-codebase\">WhatsApp from the same codebase<\/a><\/li>\n\n\n\n<li><a href=\"#delivery-reports-the-webhook-receiver\" data-type=\"internal\" data-id=\"#delivery-reports-the-webhook-receiver\">Delivery reports: the webhook receiver<\/a><\/li>\n\n\n\n<li><a href=\"#delivery-reports-the-poller\" data-type=\"internal\" data-id=\"#delivery-reports-the-poller\">Delivery reports: the poller<\/a><\/li>\n\n\n\n<li><a href=\"#resilience4j-rate-limiter-bulkhead-and-circuit-breaker\" data-type=\"internal\" data-id=\"#resilience4j-rate-limiter-bulkhead-and-circuit-breaker\">Resilience4j: rate limiter, bulkhead and circuit breaker<\/a><\/li>\n\n\n\n<li><a href=\"#micrometer-instrumentation\" data-type=\"internal\" data-id=\"#micrometer-instrumentation\">Micrometer instrumentation<\/a><\/li>\n\n\n\n<li><a href=\"#testing-with-junit-5-and-mockwebserver\" data-type=\"internal\" data-id=\"#testing-with-junit-5-and-mockwebserver\">Testing with JUnit 5 and MockWebServer<\/a><\/li>\n\n\n\n<li><a href=\"#security-and-secret-handling\" data-type=\"internal\" data-id=\"#security-and-secret-handling\">Security and secret handling<\/a><\/li>\n\n\n\n<li><a href=\"#the-ten-step-build-order\" data-type=\"internal\" data-id=\"#the-ten-step-build-order\">The ten-step build order<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\" data-type=\"internal\" data-id=\"#decision-matrix\">Decision matrix<\/a><\/li>\n\n\n\n<li><a href=\"#production-checklist\" data-type=\"internal\" data-id=\"#production-checklist\">Production checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\" data-type=\"internal\" data-id=\"#ten-mistakes\">Ten mistakes<\/a><\/li>\n\n\n\n<li><a href=\"#what-this-article-deliberately-does-not-claim\" data-type=\"internal\" data-id=\"#what-this-article-deliberately-does-not-claim\">What this article deliberately does not claim<\/a><\/li>\n\n\n\n<li><a href=\"#faq\" data-type=\"internal\" data-id=\"#faq\">FAQ<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"executive-summary\" class=\"wp-block-heading\">Executive summary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">To send an SMS from Java you POST an <code>application\/x-www-form-urlencoded<\/code> body to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> with <code>userid<\/code>, <code>password<\/code> or an <code>apiKey<\/code> header, <code>sendMethod=quick<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>senderid<\/code>, <code>msgType<\/code> and <code>output=json<\/code>. 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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/messaging-java-spring-boot-integration.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/messaging-java-spring-boot-integration-1024x584.webp\" alt=\"Geometric illustration of a Java application stack sending messages through a queue to mobile devices with delivery receipts returning\" class=\"wp-image-2711\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/messaging-java-spring-boot-integration-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/messaging-java-spring-boot-integration-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/messaging-java-spring-boot-integration-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/messaging-java-spring-boot-integration.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Java has a specific and unusually dangerous set of defaults here. <code>WebClient<\/code> on Reactor Netty applies no response timeout unless you configure one. <code>RestTemplate<\/code> with a bare <code>SimpleClientHttpRequestFactory<\/code> 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 <code>Channel Name<\/code> 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. <code>Matcher.appendReplacement<\/code> treats a dollar sign in a replacement string as a group reference, which will silently corrupt a DLT variable substitution when a customer&#8217;s name or an order reference contains one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python<\/a> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Use <code>WebClient<\/code>, and set both a connect timeout and a response timeout explicitly. Neither exists by default.<\/li>\n\n\n\n<li>Build the body as a <code>MultiValueMap&lt;String, String><\/code> and send it as <code>APPLICATION_FORM_URLENCODED<\/code>. Sending JSON to this endpoint does not fail as a 400. It fails as an authentication error, because the parameters simply are not there.<\/li>\n\n\n\n<li>Configure Jackson to tolerate an inconsistent contract. <code>statusCode<\/code> arrives quoted from <code>SMSApi\/send<\/code> and unquoted from <code>WAApi\/send<\/code>. The by-transaction report endpoint returns field names containing spaces and mixed capitalisation. Both need explicit handling.<\/li>\n\n\n\n<li>Turn Spring Retry off on the send path. Retry the read endpoints, never the write.<\/li>\n\n\n\n<li>Count segments with <code>codePointCount<\/code>, not <code>length<\/code>. Java strings are UTF-16 and <code>length<\/code> counts code units.<\/li>\n\n\n\n<li>Render DLT variables by positional substitution, not by <code>String.replaceAll<\/code> and not by <code>Matcher.appendReplacement<\/code> without <code>Matcher.quoteReplacement<\/code>.<\/li>\n\n\n\n<li>Claim the row in the database before the HTTP call, inside a transaction, behind a unique constraint. An ambiguous outcome moves to <code>UNKNOWN<\/code> and never back to <code>PENDING<\/code>.<\/li>\n\n\n\n<li>Verify the webhook signature over the raw bytes with <code>@RequestBody byte[]<\/code> and <code>MessageDigest.isEqual<\/code>, before any parsing.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>Test at the transport layer with MockWebServer. A test suite that really sends is a test suite that really bills.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"why-java-needs-its-own-guide\" class=\"wp-block-heading\">Why Java needs its own guide<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Five things are specifically Java-shaped here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Strings are UTF-16.<\/strong> <code>\"\ud83d\ude00\".length()<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Jackson binds by name and by type.<\/strong> Loosely typed languages shrug at a field that is sometimes <code>\"200\"<\/code> and sometimes <code>200<\/code>. Java throws <code>MismatchedInputException<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Spring makes retry a one-line decision.<\/strong> <code>@Retryable<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reactor Netty&#8217;s default is no timeout.<\/strong> This is the same trap as <code>fetch<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Regex replacement strings are interpreted.<\/strong> <code>$<\/code> and <code>\\<\/code> are metacharacters in a Java replacement string. This is the exact analogue of the JavaScript <code>$&amp;<\/code> hazard documented in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js tutorial<\/a>, 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"is-there-an-official-java-sdk\" class=\"wp-block-heading\">Is there an official Java SDK<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">No. As of the date on this article, the SDK section of the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">developer documentation<\/a> lists a PHP SDK and a Python SDK, and nothing else. There is a <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">Postman collection<\/a> which is useful as a source of request fixtures, but there is no Maven or Gradle artifact to depend on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want a search across the whole endpoint surface before you start, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-messaging-api\/\">SMS messaging API index<\/a> enumerates every endpoint in one page.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"choosing-the-http-client\" class=\"wp-block-heading\">Choosing the HTTP client<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Client<\/th><th>Default connect timeout<\/th><th>Default read or response timeout<\/th><th>Verdict for messaging<\/th><\/tr><\/thead><tbody><tr><td><code>RestTemplate<\/code> with <code>SimpleClientHttpRequestFactory<\/code><\/td><td>none<\/td><td>none<\/td><td>Do not use unconfigured. Two silent infinities.<\/td><\/tr><tr><td><code>RestTemplate<\/code> with <code>HttpComponentsClientHttpRequestFactory<\/code><\/td><td>from the Apache client config<\/td><td>from the Apache client config<\/td><td>Acceptable if you configure both explicitly.<\/td><\/tr><tr><td><code>WebClient<\/code> on Reactor Netty<\/td><td>none unless set on the <code>HttpClient<\/code><\/td><td>none unless <code>responseTimeout<\/code> is set<\/td><td>Recommended, but only after configuration.<\/td><\/tr><tr><td><code>RestClient<\/code> (Spring 6.1+)<\/td><td>delegates to the underlying factory<\/td><td>delegates to the underlying factory<\/td><td>Fine. Same configuration burden.<\/td><\/tr><tr><td><code>java.net.http.HttpClient<\/code><\/td><td>none<\/td><td>none unless <code>.timeout()<\/code> per request<\/td><td>Usable. The per-request timeout is easy to forget.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The recommendation is <a href=\"https:\/\/docs.spring.io\/spring-framework\/reference\/\" target=\"_blank\" rel=\"noopener nofollow\"><code>WebClient<\/code>,<\/a> 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 <code>.block()<\/code> from an ordinary imperative service and lose nothing that matters here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The trap to avoid is thinking that because you are not writing reactive code, <code>RestTemplate<\/code> is simpler. It is not simpler. It has the same two infinite defaults and it scatters the fix across every call site.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-timeout-problem-in-full\" class=\"wp-block-heading\">The timeout problem, in full<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the client bean. Every number in it is a decision, not a default.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>package com.example.messaging.http;\n\nimport io.netty.channel.ChannelOption;\nimport io.netty.handler.timeout.ReadTimeoutHandler;\nimport io.netty.handler.timeout.WriteTimeoutHandler;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.client.reactive.ReactorClientHttpConnector;\nimport org.springframework.web.reactive.function.client.ExchangeStrategies;\nimport org.springframework.web.reactive.function.client.WebClient;\nimport reactor.netty.http.client.HttpClient;\nimport reactor.netty.resources.ConnectionProvider;\n\nimport java.time.Duration;\nimport java.util.concurrent.TimeUnit;\n\n@Configuration\npublic class MessagingHttpConfig {\n\n    @Bean\n    ConnectionProvider messagingConnectionProvider() {\n        return ConnectionProvider.builder(\"sgc\")\n                .maxConnections(50)\n                \/\/ Fail fast rather than queue forever when the pool is exhausted.\n                .pendingAcquireTimeout(Duration.ofSeconds(3))\n                .pendingAcquireMaxCount(200)\n                .maxIdleTime(Duration.ofSeconds(30))\n                .maxLifeTime(Duration.ofMinutes(10))\n                \/\/ Cheap insurance against a half-open connection returned from the pool.\n                .evictInBackground(Duration.ofSeconds(30))\n                .build();\n    }\n\n    @Bean\n    WebClient messagingWebClient(ConnectionProvider provider) {\n        HttpClient httpClient = HttpClient.create(provider)\n                \/\/ TCP connect only. Does not cover TLS negotiation or the response.\n                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000)\n                \/\/ The one that actually matters. Absent by default.\n                .responseTimeout(Duration.ofSeconds(15))\n                .doOnConnected(conn -&gt; conn\n                        .addHandlerLast(new ReadTimeoutHandler(15, TimeUnit.SECONDS))\n                        .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS)))\n                .compress(true);\n\n        return WebClient.builder()\n                .baseUrl(\"https:\/\/unify.smsgateway.center\")\n                .clientConnector(new ReactorClientHttpConnector(httpClient))\n                .defaultHeader(\"Content-Type\", MediaType.APPLICATION_FORM_URLENCODED_VALUE)\n                .exchangeStrategies(ExchangeStrategies.builder()\n                        .codecs(c -&gt; c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))\n                        .build())\n                .build();\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three notes on that bean.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>responseTimeout<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pendingAcquireTimeout<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>maxInMemorySize<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"connect-timeout-versus-read-timeout\" class=\"wp-block-heading\">Connect timeout versus read timeout<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/connect-timeout-versus-read-timeout.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/connect-timeout-versus-read-timeout.webp\" alt=\"Illustration about Connect timeout versus read timeout\" class=\"wp-image-2712\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/connect-timeout-versus-read-timeout.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/connect-timeout-versus-read-timeout-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/connect-timeout-versus-read-timeout-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This is the most important table in the article, and it is the one that decides whether your retry logic bills the customer twice.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Java exception<\/th><th>What happened<\/th><th>Was the message submitted<\/th><th>Safe to retry<\/th><\/tr><\/thead><tbody><tr><td><code>ConnectTimeoutException<\/code><\/td><td>TCP handshake never completed<\/td><td>No<\/td><td>Yes, immediately<\/td><\/tr><tr><td><code>UnknownHostException<\/code><\/td><td>DNS failed<\/td><td>No<\/td><td>Yes, after DNS recovers<\/td><\/tr><tr><td><code>SSLHandshakeException<\/code><\/td><td>TLS negotiation failed<\/td><td>No<\/td><td>Yes, but fix the trust store first<\/td><\/tr><tr><td><code>WebClientRequestException<\/code> wrapping <code>ConnectException<\/code><\/td><td>Connection refused<\/td><td>No<\/td><td>Yes<\/td><\/tr><tr><td><code>ReadTimeoutException<\/code><\/td><td>Request was written, no response arrived<\/td><td><strong>Unknown<\/strong><\/td><td><strong>No<\/strong><\/td><\/tr><tr><td><code>PrematureCloseException<\/code> after request write<\/td><td>Connection dropped mid-response<\/td><td><strong>Unknown<\/strong><\/td><td><strong>No<\/strong><\/td><\/tr><tr><td>HTTP 5xx with a body<\/td><td>Server responded<\/td><td>Almost certainly not, but confirm<\/td><td>Usually yes<\/td><\/tr><tr><td>HTTP 200 with <code>status<\/code> of <code>error<\/code><\/td><td>Server responded and rejected it<\/td><td>No<\/td><td>Yes, after fixing the cause<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The distinction that Java makes easy and that most integrations throw away: <code>ConnectTimeoutException<\/code> and <code>ReadTimeoutException<\/code> are different classes. Do not catch <code>Exception<\/code> on the send path. Do not catch <code>WebClientRequestException<\/code> and treat every instance the same. Classify.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>package com.example.messaging.http;\n\nimport io.netty.handler.timeout.ReadTimeoutException;\nimport io.netty.channel.ConnectTimeoutException;\nimport org.springframework.web.reactive.function.client.WebClientRequestException;\nimport reactor.netty.http.client.PrematureCloseException;\n\nimport java.net.ConnectException;\nimport java.net.UnknownHostException;\n\npublic enum SendOutcome {\n    NOT_SUBMITTED,   \/\/ provably nothing reached the platform\n    SUBMITTED,       \/\/ a response was parsed\n    UNKNOWN;         \/\/ it may or may not have been accepted and billed\n\n    public static SendOutcome classify(Throwable t) {\n        Throwable cause = t instanceof WebClientRequestException &amp;&amp; t.getCause() != null\n                ? t.getCause()\n                : t;\n\n        if (cause instanceof ConnectTimeoutException\n                || cause instanceof ConnectException\n                || cause instanceof UnknownHostException) {\n            return NOT_SUBMITTED;\n        }\n        if (cause instanceof ReadTimeoutException\n                || cause instanceof PrematureCloseException) {\n            return UNKNOWN;\n        }\n        \/\/ Anything unrecognised is UNKNOWN. Defaulting to NOT_SUBMITTED here\n        \/\/ is how a library upgrade turns into a duplicate billing incident.\n        return UNKNOWN;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full treatment of what to do once an outcome is <code>UNKNOWN<\/code> lives in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">Message Idempotency at Scale<\/a>. This article implements the Java side of that design in the claim-before-send section below.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"orm-encoding-and-why-json-looks-like-an-auth-failure\" class=\"wp-block-heading\">Form encoding, and why JSON looks like an auth failure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">send endpoint<\/a> accepts <code>application\/x-www-form-urlencoded<\/code>. It also documents JSON and XML request bodies for the batch shape, using an <code>sms<\/code> array. What it does not do is accept an arbitrary JSON object with your parameters at the top level.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is worth spelling out because the failure is misleading. If you send <code>{\"userid\":\"...\",\"password\":\"...\",\"mobile\":\"...\"}<\/code> as <code>application\/json<\/code> to the quick-send path, the platform does not see a malformed request. It sees a request with no <code>userid<\/code> and no <code>password<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In Spring, the fix is to build a <code>MultiValueMap<\/code> and let <code>BodyInserters.fromFormData<\/code> encode it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>MultiValueMap&lt;String, String&gt; form = new LinkedMultiValueMap&lt;&gt;();\nform.add(\"userid\", credentials.userid());\nform.add(\"password\", credentials.password());\nform.add(\"sendMethod\", \"quick\");\nform.add(\"msgType\", \"text\");\nform.add(\"senderid\", senderId);\nform.add(\"mobile\", String.join(\",\", recipients));   \/\/ comma separated\nform.add(\"msg\", body);\nform.add(\"output\", \"json\");\nform.add(\"duplicatecheck\", \"true\");\n\nwebClient.post()\n        .uri(\"\/SMSApi\/send\")\n        .contentType(MediaType.APPLICATION_FORM_URLENCODED)\n        .body(BodyInserters.fromFormData(form))\n        .retrieve();\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details that are easy to get wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do not URL-encode the values yourself before adding them to the map. <code>fromFormData<\/code> encodes. Pre-encoding produces double-encoded percent signs, and a message body containing a percent sign is common enough that you will hit it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The recipient delimiter is a comma. The documentation describes &#8220;lists of comma-separated mobile numbers&#8221; and its own samples tolerate a space after the comma. Use <code>String.join(\",\", ...)<\/code> and do not invent a semicolon or a newline separator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, note that the password must be URL-encoded when it contains special characters. <code>fromFormData<\/code> handles this correctly. Hand-built query strings do not, and a password containing a <code>+<\/code> will authenticate as a password containing a space.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"jackson-at-the-boundary\" class=\"wp-block-heading\">Jackson at the boundary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The single most Java-specific piece of work in this integration is making <a href=\"https:\/\/github.com\/FasterXML\/jackson-databind\" target=\"_blank\" rel=\"noopener nofollow\">Jackson<\/a> survive a response contract that was not designed with a statically typed binder in mind.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Start with an ObjectMapper that is deliberately lenient about shape and strict about nothing else.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@Bean\n@Qualifier(\"messaging\")\nObjectMapper messagingObjectMapper() {\n    return JsonMapper.builder()\n            \/\/ New optional fields must not break an existing deployment.\n            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)\n            \/\/ \"\" for an absent numeric field is common in this contract.\n            .enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)\n            \/\/ Single-element arrays sometimes arrive unwrapped.\n            .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)\n            .serializationInclusion(JsonInclude.Include.NON_NULL)\n            .build();\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>FAIL_ON_UNKNOWN_PROPERTIES<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-response-contract-is-not-consistent-and-java-notices-first\" class=\"wp-block-heading\">The response contract is not consistent, and Java notices first<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three concrete inconsistencies, all of which are harmless in a dynamic language and all of which are a runtime exception in Java.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. <code>statusCode<\/code> is quoted on SMS and unquoted on WhatsApp.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented <code>SMSApi\/send<\/code> success response is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\"status\":\"success\",\"mobile\":\"919999999999\",\"invalidMobile\":\"\",\"transactionId\":\"6305583318236810379\",\"statusCode\":\"200\",\"reason\":\"success\"}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The documented <code>WAApi\/send<\/code> success response is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\"status\":\"success\",\"messageId\":\"1234567890\",\"mobile\":\"919xxxxxxxxx6\",\"statusCode\":200,\"description\":\"Message sent successfully\"}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Same field name, different JSON type. Also note that the identifier is <code>transactionId<\/code> on one and <code>messageId<\/code> on the other, and the human-readable field is <code>reason<\/code> on one and <code>description<\/code> on the other. Bind <code>statusCode<\/code> with a deserializer that accepts either.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public class LenientStringDeserializer extends JsonDeserializer&lt;String&gt; {\n    @Override\n    public String deserialize(JsonParser p, DeserializationContext ctx) throws IOException {\n        JsonToken t = p.currentToken();\n        if (t == JsonToken.VALUE_NUMBER_INT || t == JsonToken.VALUE_NUMBER_FLOAT) {\n            return p.getText();\n        }\n        if (t == JsonToken.VALUE_STRING) {\n            String s = p.getText();\n            return s == null ? null : s.trim();\n        }\n        if (t == JsonToken.VALUE_NULL) {\n            return null;\n        }\n        return p.getValueAsString();\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then normalise both channels onto one internal record, so that nothing downstream of the client ever has to know which channel it came from.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public record SubmitResult(\n        String status,          \/\/ \"success\" | \"error\"\n        String statusCode,      \/\/ always a String internally\n        String providerId,      \/\/ transactionId or messageId\n        String detail,          \/\/ reason or description\n        String invalidMobile,   \/\/ SMS only, empty string when none\n        String rawBody          \/\/ keep it, you will want it during an incident\n) {\n    public boolean accepted() {\n        return \"success\".equalsIgnoreCase(status);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Keeping <code>rawBody<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. The by-transaction report endpoint returns field names with spaces.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This one is new and it is the sharpest Java hazard in the whole contract. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/check-mis-by-transaction-id\/\">Check SMS Delivery Report by Transaction ID<\/a> endpoint, which is <code>SMSApi\/reports\/status<\/code> called with a <code>uuid<\/code> plus <code>fromdate<\/code> and <code>todate<\/code>, returns a documented shape like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"send\",\n    \"action\": \"status\",\n    \"status\": \"success\",\n    \"msg\": \"success\",\n    \"code\": \"200\",\n    \"count\": 1,\n    \"report_statusList\": &#91;\n      {\n        \"status\": {\n          \"uuId\": \"8359251506264886974\",\n          \"msgId\": \"QudUrwhMuIGkbs2\",\n          \"mobileNo\": \"919999999999\",\n          \"senderName\": \"CBISTS\",\n          \"text\": \"Hello world\",\n          \"msgType\": \"text\",\n          \"length\": \"11\",\n          \"cost\": \"1\",\n          \"Status\": \"FAILED\",\n          \"Cause\": \"Unknown User\",\n          \"Channel Name\": \"API\",\n          \"Submitted Time\": \"July 13, 2019 03:44:48\",\n          \"Delivered Time\": \"July 13, 2019 03:44:49\"\n        }\n      }\n    ]\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Look at what that does to a POJO. <code>status<\/code> appears twice at different nesting levels with different meanings, once as an envelope field and once as the delivery state. <code>Status<\/code> and <code>status<\/code> differ only by capitalisation and mean different things in the same object. <code>Channel Name<\/code>, <code>Submitted Time<\/code> and <code>Delivered Time<\/code> 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 <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No amount of naming strategy configuration fixes this. Every field needs an explicit <code>@JsonProperty<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public class TransactionStatusRow {\n    @JsonProperty(\"uuId\")          public String uuId;\n    @JsonProperty(\"msgId\")         public String msgId;\n    @JsonProperty(\"mobileNo\")      public String mobileNo;\n    @JsonProperty(\"senderName\")    public String senderName;\n    @JsonProperty(\"text\")          public String text;\n    @JsonProperty(\"msgType\")       public String msgType;\n    @JsonProperty(\"length\")        public String length;\n    @JsonProperty(\"cost\")          public String cost;   \/\/ parse to BigDecimal, never double\n    @JsonProperty(\"Status\")        public String deliveryStatus;\n    @JsonProperty(\"Cause\")         public String cause;\n    @JsonProperty(\"Channel Name\")  public String channelName;\n    @JsonProperty(\"Submitted Time\") public String submittedTime;\n    @JsonProperty(\"Delivered Time\") public String deliveredTime;\n}\n\npublic class TransactionStatusWrapper {\n    @JsonProperty(\"status\") public TransactionStatusRow status;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>cost<\/code> as a <code>String<\/code>. Parse it into <code>BigDecimal<\/code>, never <code>double<\/code>. Currency in a binary floating point type is a defect waiting for a reconciliation report.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. The same parameter is spelled two ways across the docs.<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The send documentation uses lowercase <code>duplicatecheck<\/code> in its parameter table and in some samples, and camelCase <code>duplicateCheck<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">While you are here: <code>duplicatecheck<\/code> means &#8220;remove duplicate mobile numbers&#8221; 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"configuration-and-credentials\" class=\"wp-block-heading\">Configuration and credentials<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>@ConfigurationProperties(prefix = \"sgc\")\n@Validated\npublic record SgcProperties(\n        @NotBlank String baseUrl,\n        @NotBlank String userid,\n        String password,\n        String apiKey,\n        @NotBlank String senderId,\n        String dltEntityId,\n        Duration responseTimeout,\n        int maxRecipientsPerRequest\n) {\n    public SgcProperties {\n        if ((password == null || password.isBlank()) &amp;&amp; (apiKey == null || apiKey.isBlank())) {\n            throw new IllegalStateException(\"Set exactly one of sgc.password or sgc.api-key\");\n        }\n        if (maxRecipientsPerRequest &lt;= 0) {\n            maxRecipientsPerRequest = 1000;\n        }\n    }\n\n    \/\/ Prevent the credential leaking through a logged config dump.\n    @Override\n    public String toString() {\n        return \"SgcProperties&#91;baseUrl=%s, userid=%s, senderId=%s, password=***, apiKey=***]\"\n                .formatted(baseUrl, userid, senderId);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Overriding <code>toString<\/code> on a record is the Java equivalent of the <code>__debugInfo<\/code> redaction used in the PHP tutorial and the logging filter used in the Python one. Spring Boot&#8217;s actuator, a <code>@ConfigurationProperties<\/code> binding failure message, and half the logging in a typical application will call <code>toString<\/code> on this object at some point. Records generate a <code>toString<\/code> that prints every component, including the password. Override it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For key management specifically, see the guide to <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/create-use-sms-gateway-center-api-key\/\">creating and using your API key<\/a>. The <code>apiKey<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-send-client\" class=\"wp-block-heading\">The send client<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>@Service\npublic class SgcSmsClient {\n\n    private static final Logger log = LoggerFactory.getLogger(SgcSmsClient.class);\n\n    private final WebClient webClient;\n    private final SgcProperties props;\n    private final ObjectMapper mapper;\n\n    public SgcSmsClient(WebClient messagingWebClient,\n                        SgcProperties props,\n                        @Qualifier(\"messaging\") ObjectMapper mapper) {\n        this.webClient = messagingWebClient;\n        this.props = props;\n        this.mapper = mapper;\n    }\n\n    \/**\n     * Submits one send request. Throws nothing that a caller can safely retry\n     * without first consulting SendOutcome.classify.\n     *\/\n    public SubmitResult send(SendRequest req) {\n        MultiValueMap&lt;String, String&gt; form = new LinkedMultiValueMap&lt;&gt;();\n        applyAuth(form);\n        form.add(\"sendMethod\", \"quick\");\n        form.add(\"msgType\", req.unicode() ? \"unicode\" : \"text\");\n        form.add(\"senderid\", req.senderId() != null ? req.senderId() : props.senderId());\n        form.add(\"mobile\", String.join(\",\", req.recipients()));\n        form.add(\"msg\", req.body());\n        form.add(\"output\", \"json\");\n        form.add(\"duplicatecheck\", \"true\");\n\n        if (req.dltTemplateId() != null) {\n            form.add(\"dltTemplateId\", req.dltTemplateId());\n            if (props.dltEntityId() != null) {\n                form.add(\"dltEntityId\", props.dltEntityId());\n            }\n        }\n        if (req.scheduleTime() != null) {\n            \/\/ Documented format: YYYY-MM-DD HH:MM:SS\n            form.add(\"scheduleTime\",\n                    req.scheduleTime().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss\")));\n        }\n        if (req.testMode()) {\n            form.add(\"testMessage\", \"true\");\n        }\n\n        WebClient.RequestBodySpec spec = webClient.post()\n                .uri(\"\/SMSApi\/send\")\n                .contentType(MediaType.APPLICATION_FORM_URLENCODED);\n\n        if (props.apiKey() != null &amp;&amp; !props.apiKey().isBlank()) {\n            spec = spec.header(\"apiKey\", props.apiKey());\n        }\n\n        String raw = spec.body(BodyInserters.fromFormData(form))\n                \/\/ Do NOT use onStatus to throw here. A non-2xx body still\n                \/\/ carries a statusCode and reason worth recording.\n                .exchangeToMono(resp -&gt; resp.bodyToMono(String.class)\n                        .defaultIfEmpty(\"\")\n                        .map(b -&gt; b))\n                .block();\n\n        return parse(raw);\n    }\n\n    private void applyAuth(MultiValueMap&lt;String, String&gt; form) {\n        form.add(\"userid\", props.userid());\n        if (props.apiKey() == null || props.apiKey().isBlank()) {\n            form.add(\"password\", props.password());\n        }\n    }\n\n    private SubmitResult parse(String raw) {\n        try {\n            JsonNode n = mapper.readTree(raw == null ? \"\" : raw);\n            return new SubmitResult(\n                    text(n, \"status\"),\n                    text(n, \"statusCode\"),\n                    firstNonNull(text(n, \"transactionId\"), text(n, \"messageId\")),\n                    firstNonNull(text(n, \"reason\"), text(n, \"description\")),\n                    text(n, \"invalidMobile\"),\n                    raw\n            );\n        } catch (JsonProcessingException e) {\n            \/\/ A body that will not parse is not a success. Treat as UNKNOWN upstream.\n            throw new AmbiguousSubmitException(\"unparseable response body\", e, raw);\n        }\n    }\n\n    private static String text(JsonNode n, String field) {\n        JsonNode v = n.get(field);\n        return v == null || v.isNull() ? null : v.asText();\n    }\n\n    private static String firstNonNull(String a, String b) {\n        return a != null ? a : b;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The one thing in that listing that will look wrong to an experienced Spring developer is <code>exchangeToMono<\/code> returning the raw body instead of <code>retrieve().bodyToMono(...)<\/code> with an <code>onStatus<\/code> handler that throws. That is deliberate. <code>retrieve()<\/code> with the default status handler converts a 4xx or 5xx into a <code>WebClientResponseException<\/code> and, depending on how you configure it, discards or buries the body. In this API the body is the diagnostic. A rejection carries a <code>statusCode<\/code> and a <code>reason<\/code> that tell you whether the sender ID is blocked, the template mismatched, or the balance is exhausted. Throwing that away and reporting &#8220;500 Internal Server Error&#8221; to your operations team is a self-inflicted wound.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note also <code>invalidMobile<\/code>. On a multi-recipient request, the top-level <code>status<\/code> can read <code>success<\/code> while individual numbers were rejected and enumerated in <code>invalidMobile<\/code>. If you treat <code>status == success<\/code> as &#8220;all recipients accepted&#8221;, you will silently drop recipients. Parse <code>invalidMobile<\/code>, and if it is non-empty, mark those specific rows as rejected rather than submitted.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"authentication-api-key-and-now-oauth-20-with-pkce\" class=\"wp-block-heading\">Authentication: API key, and now OAuth 2.0 with PKCE<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most integrations use one of two mechanisms, both documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/authentication\/\">authentication page<\/a>: <code>userid<\/code> plus <code>password<\/code> as form parameters, or an <code>apiKey<\/code> sent as an HTTP header while <code>userid<\/code> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-api-key\/\">Create API Key endpoint<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is now a third mechanism, and it changes what a Java integration can be. The platform documents a full <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/oauth-api\/\">REST OAuth API<\/a> implementing OAuth 2.0 Authorization Code with PKCE. This matters if you are building a product that sends on behalf of <em>other people&#8217;s<\/em> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented endpoints are:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Method<\/th><th>Purpose<\/th><\/tr><\/thead><tbody><tr><td><code>https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/authorize<\/code><\/td><td>GET<\/td><td>Start user sign-in and authorization<\/td><\/tr><tr><td><code>https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/token<\/code><\/td><td>POST<\/td><td>Exchange an authorization code or refresh token<\/td><\/tr><tr><td><code>https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/revoke<\/code><\/td><td>POST<\/td><td>Revoke a refresh token or access token<\/td><\/tr><tr><td><code>https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/introspect<\/code><\/td><td>POST<\/td><td>Check whether an access token is active and resolve the username<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Documented token lifetimes: the authorization code lasts 10 minutes, the access token 3600 seconds, and the refresh token 30 days. Only the <code>S256<\/code> code challenge method is supported. Client registration is manual, through <a href=\"https:\/\/www.smsgatewaycenter.com\/support\/\">support<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is one integration detail here that will trip up anyone who has wired OAuth before, and it is the thing to internalise. <strong>The access token does not carry the account identity for the SMS APIs.<\/strong> The token response returns <code>access_token<\/code>, <code>refresh_token<\/code> and <code>username<\/code>, and the documentation is explicit: you must send the access token as a bearer header or as the <code>apikey<\/code> header, <em>and<\/em> you must still send <code>userid<\/code> set to that <code>username<\/code> on every SMS API request. OAuth replaces credential entry. It does not replace the request format.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Concretely, after the exchange your send call looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s -X POST \"https:\/\/unify.smsgateway.center\/SMSApi\/send\" \\\n  -H \"Authorization: Bearer YOUR_ACCESS_TOKEN\" \\\n  -H \"Content-Type: application\/x-www-form-urlencoded\" \\\n  --data-urlencode \"userid=USERNAME_FROM_TOKEN_RESPONSE\" \\\n  --data-urlencode \"output=json\" \\\n  --data-urlencode \"sendMethod=quick\" \\\n  --data-urlencode \"mobile=919999999999\" \\\n  --data-urlencode \"senderid=YOUR_SENDER_ID\" \\\n  --data-urlencode \"msg=Your order is ready.\" \\\n  --data-urlencode \"msgType=text\" \\\n  --data-urlencode \"duplicatecheck=true\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In Spring, the natural home for this is <code>spring-boot-starter-oauth2-client<\/code> with a registration whose provider points at those URIs. The one adjustment is that the token response contains a non-standard <code>username<\/code> claim you need to persist alongside the tokens, so a custom <code>OAuth2AccessTokenResponseClient<\/code> or a post-exchange listener is required.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@Bean\nClientRegistrationRepository sgcClientRegistrations(\n        @Value(\"${sgc.oauth.client-id}\") String clientId,\n        @Value(\"${sgc.oauth.client-secret}\") String clientSecret,\n        @Value(\"${sgc.oauth.redirect-uri}\") String redirectUri) {\n\n    ClientRegistration sgc = ClientRegistration.withRegistrationId(\"sgc\")\n            .clientId(clientId)\n            .clientSecret(clientSecret)\n            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)\n            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n            .redirectUri(redirectUri)\n            .authorizationUri(\"https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/authorize\")\n            .tokenUri(\"https:\/\/unify.smsgateway.center\/rest\/oauth\/v1\/token\")\n            .clientName(\"SMSGatewayCenter\")\n            .build();\n\n    return new InMemoryClientRegistrationRepository(sgc);\n}\n\n\/\/ PKCE is not enabled by default for confidential clients in Spring Security.\n\/\/ Only S256 is supported by the platform, which is what this resolver emits.\n@Bean\nOAuth2AuthorizationRequestResolver pkceResolver(ClientRegistrationRepository repo) {\n    DefaultOAuth2AuthorizationRequestResolver resolver =\n            new DefaultOAuth2AuthorizationRequestResolver(repo, \"\/oauth2\/authorization\");\n    resolver.setAuthorizationRequestCustomizer(\n            OAuth2AuthorizationRequestCustomizers.withPkce());\n    return resolver;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>OAuth2AuthorizationRequestCustomizers.withPkce()<\/code> 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>code_challenge<\/code> and the token exchange fails with <code>invalid_grant<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Store <code>username<\/code> with the token. A token store that keeps only <code>access_token<\/code> and <code>refresh_token<\/code> will leave you with a valid credential and no idea which <code>userid<\/code> to put in the form body. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/oauth-api\/\">introspection endpoint<\/a> exists to recover from exactly that situation, returning <code>{\"active\": true, \"username\": \"...\", \"token_type\": \"Bearer\"}<\/code>, but treating it as a routine per-request lookup is an unnecessary round trip.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"counting-segments-correctly-in-java\" class=\"wp-block-heading\">Counting segments correctly in Java<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You are billed by segment, not by message. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/text-messages-chart\/\">text messages chart<\/a> documents the mapping: 160 characters per segment for English and 153 when concatenated, 70 per segment for Unicode and 67 when concatenated.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Java trap is that <code>String.length()<\/code> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public final class SegmentCounter {\n\n    private static final String GSM_BASIC =\n        \"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\\\"#\u00a4%&amp;'()*+,-.\/0123456789:;&lt;=&gt;?\"\n      + \"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\";\n\n    \/\/ Each of these occupies TWO GSM-7 septets because of the escape sequence.\n    private static final String GSM_EXTENSION = \"^{}\\\\&#91;~]|\u20ac\";\n\n    public record Count(String encoding, int units, int segments, int perSegment) {}\n\n    public static Count count(String msg) {\n        if (msg == null) msg = \"\";\n\n        boolean gsm = true;\n        int septets = 0;\n\n        \/\/ Iterate by CODE POINT. This is the whole point of the class.\n        for (int i = 0; i &lt; msg.length(); ) {\n            int cp = msg.codePointAt(i);\n            i += Character.charCount(cp);\n\n            String s = new String(Character.toChars(cp));\n            if (GSM_EXTENSION.contains(s)) {\n                septets += 2;\n            } else if (GSM_BASIC.contains(s)) {\n                septets += 1;\n            } else {\n                gsm = false;\n                break;\n            }\n        }\n\n        if (gsm) {\n            int segments = septets &lt;= 160 ? (septets == 0 ? 1 : 1)\n                                          : (int) Math.ceil(septets \/ 153.0);\n            return new Count(\"TEXT\", septets, Math.max(segments, 1), septets &lt;= 160 ? 160 : 153);\n        }\n\n        \/\/ Unicode is billed in UTF-16 code units, so length() is correct HERE\n        \/\/ and only here. An emoji genuinely occupies two units on the wire.\n        int units = msg.length();\n        int segments = units &lt;= 70 ? 1 : (int) Math.ceil(units \/ 67.0);\n        return new Count(\"UNICODE\", units, Math.max(segments, 1), units &lt;= 70 ? 70 : 67);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The comment in the Unicode branch is the subtle part. <code>length()<\/code> is wrong for counting <em>characters<\/em> and right for counting <em>UCS-2 units on the wire<\/em>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cross-check your implementation against the platform&#8217;s own <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-sms-length-cost\/\">SMS length and cost endpoint<\/a>, which POSTs a <code>msg<\/code> and returns <code>encoding<\/code>, <code>length<\/code>, <code>remaining<\/code> and <code>credit<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the full economics of why this matters, including the specific punctuation characters that silently push a message into Unicode, see <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">Bulk SMS Pricing in India: What Actually Drives Cost<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-india-dlt-layer\" class=\"wp-block-heading\">The India DLT layer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you send to Indian numbers, the message body must match a template registered on a DLT portal, and the send must carry <code>dltTemplateId<\/code> and <code>dltEntityId<\/code>. Registration itself is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-registration-step-by-step-guide-india\/\">DLT registration guide<\/a>, and the reasons templates get rejected are catalogued in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT Template Rejected: Every Reason and How to Fix It<\/a>. What follows is only the Java rendering layer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A registered template looks like <code>Dear {#var#}, your order {#var#} has shipped. Track at {#var#}<\/code>. The variables are positional and identical. There is no name to key on. This is why every correct renderer in this series is positional.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public final class DltTemplate {\n\n    private static final String TOKEN = \"{#var#}\";\n\n    private final String pattern;\n    private final int arity;\n\n    public DltTemplate(String pattern) {\n        this.pattern = Objects.requireNonNull(pattern);\n        int n = 0, idx = 0;\n        while ((idx = pattern.indexOf(TOKEN, idx)) &gt;= 0) { n++; idx += TOKEN.length(); }\n        this.arity = n;\n    }\n\n    public int arity() { return arity; }\n\n    public String render(List&lt;String&gt; values) {\n        if (values.size() != arity) {\n            throw new TemplateArityException(\n                \"template expects \" + arity + \" values, got \" + values.size());\n        }\n        \/\/ StringBuilder + indexOf. No regex, no String.replace loop.\n        StringBuilder out = new StringBuilder(pattern.length() + 64);\n        int cursor = 0;\n        for (String value : values) {\n            int idx = pattern.indexOf(TOKEN, cursor);\n            out.append(pattern, cursor, idx);\n            out.append(value == null ? \"\" : value);\n            cursor = idx + TOKEN.length();\n        }\n        out.append(pattern, cursor, pattern.length());\n        return out.toString();\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Why not a loop of <code>String.replaceFirst<\/code>? Because <code>replaceFirst<\/code> takes a regex and a replacement string, and both are interpreted. Why not <code>String.replace<\/code>, 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 <code>Matcher<\/code> and <code>appendReplacement<\/code>? See the next section.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Validate before you send, not after. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">validateTemplate endpoint<\/a> takes <code>senderid<\/code>, <code>msg<\/code> and <code>dltTemplateId<\/code> and returns either a success with the punctuation-normalised template or <code>{\"status\":\"error\",\"statusCode\":\"188\",\"reason\":\"message template mismatch\"}<\/code>. 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public boolean validates(String senderId, String rendered, String dltTemplateId) {\n    MultiValueMap&lt;String, String&gt; form = new LinkedMultiValueMap&lt;&gt;();\n    applyAuth(form);\n    form.add(\"senderid\", senderId);\n    form.add(\"msg\", rendered);\n    form.add(\"dltTemplateId\", dltTemplateId);\n    form.add(\"output\", \"json\");\n\n    String raw = webClient.post().uri(\"\/SMSApi\/validateTemplate\")\n            .contentType(MediaType.APPLICATION_FORM_URLENCODED)\n            .body(BodyInserters.fromFormData(form))\n            .exchangeToMono(r -&gt; r.bodyToMono(String.class).defaultIfEmpty(\"\"))\n            .block();\n\n    SubmitResult res = parse(raw);\n    if (\"188\".equals(res.statusCode())) {\n        log.error(\"DLT template mismatch for {}: {}\", dltTemplateId, res.detail());\n        return false;\n    }\n    return res.accepted();\n}\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-appendreplacement-trap\" class=\"wp-block-heading\">The appendReplacement trap<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the Java analogue of the JavaScript <code>$&amp;<\/code> hazard, and it is worse, because Java has two metacharacters in a replacement string rather than one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In <code>Matcher.appendReplacement(StringBuffer, String)<\/code> and in <code>String.replaceAll(String, String)<\/code>, the replacement argument is not literal text. A <code>$<\/code> followed by a digit is a capture group reference. A <code>${name}<\/code> is a named group reference. A backslash escapes. So:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Looks correct. Is a defect.\nString out = template.replaceAll(\"\\\\{#var#\\\\}\", customerName);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If <code>customerName<\/code> is <code>A$1 Motors<\/code>, <code>$1<\/code> is interpreted as a reference to capture group 1. The pattern has no group 1, so this throws <code>IndexOutOfBoundsException<\/code> at runtime, from an input that arrived from a customer record you did not control. If the pattern <em>does<\/em> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you must use a matcher, quote the replacement:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Matcher m = TOKEN_PATTERN.matcher(template);\nStringBuilder sb = new StringBuilder();\nIterator&lt;String&gt; it = values.iterator();\nwhile (m.find()) {\n    \/\/ Matcher.quoteReplacement neutralises $ and \\ in the replacement.\n    m.appendReplacement(sb, Matcher.quoteReplacement(it.next()));\n}\nm.appendTail(sb);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Matcher.quoteReplacement<\/code> 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 <code>StringBuilder<\/code> and <code>indexOf<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"spring-retry-configured-not-to-retry\" class=\"wp-block-heading\">Spring Retry, configured not to retry<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Spring makes retry a single annotation, which is why Spring applications tend to have the worst duplicate-message problems in this series.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Do not do this on a send path.\n@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))\npublic SubmitResult send(SendRequest req) { ... }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That annotation retries on any exception by default. <code>ReadTimeoutException<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@Bean\nRetryTemplate readRetryTemplate() {\n    return RetryTemplate.builder()\n            .maxAttempts(4)\n            .exponentialBackoff(500, 2.0, 8_000)\n            .retryOn(List.of(WebClientRequestException.class, IOException.class))\n            .withListener(new MetricsRetryListener())\n            .build();\n}\n\n@Bean\nRetryTemplate sendRetryTemplate() {\n    return RetryTemplate.builder()\n            .maxAttempts(3)\n            .exponentialBackoff(1_000, 2.0, 10_000)\n            \/\/ Retry ONLY the outcomes we can prove did not reach the platform.\n            .retryOn(NotSubmittedException.class)\n            .build();\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>NotSubmittedException<\/code> is thrown only from the <code>NOT_SUBMITTED<\/code> branch of the classifier defined earlier. Nothing else can enter the send retry template. If a new exception type appears, it is classified <code>UNKNOWN<\/code>, it is not a <code>NotSubmittedException<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add full jitter to the backoff if you are retrying at any scale. Spring Retry&#8217;s <code>ExponentialBackOffPolicy<\/code> supports a random multiplier via <code>ExponentialRandomBackOffPolicy<\/code>; 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API Retry Strategy: Handling Failed Messages<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A note on <code>@Async<\/code> and <code>@Retryable<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"claim-before-send\" class=\"wp-block-heading\">Claim before send<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send.webp\" alt=\"Illustration about Claim before send\" class=\"wp-image-2713\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/claim-before-send-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE outbound_message (\n    id              BIGSERIAL PRIMARY KEY,\n    business_key    TEXT NOT NULL,\n    channel         TEXT NOT NULL,\n    recipient       TEXT NOT NULL,\n    body            TEXT NOT NULL,\n    dlt_template_id TEXT,\n    state           TEXT NOT NULL DEFAULT 'PENDING',\n    attempt         INT  NOT NULL DEFAULT 0,\n    provider_id     TEXT,\n    status_code     TEXT,\n    units           INT,\n    encoding        TEXT,\n    cost            NUMERIC(12,4),\n    claimed_at      TIMESTAMPTZ,\n    submitted_at    TIMESTAMPTZ,\n    delivered_at    TIMESTAMPTZ,\n    last_error      TEXT,\n    CONSTRAINT uq_outbound_business_key UNIQUE (business_key)\n);\n\nCREATE INDEX ix_outbound_claim\n    ON outbound_message (state, id) WHERE state = 'PENDING';\n\nCREATE INDEX ix_outbound_unknown\n    ON outbound_message (state, submitted_at) WHERE state = 'UNKNOWN';\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>business_key<\/code> is deterministic and derived from the business event, not from the attempt. Something like <code>order-shipped:{orderId}:{recipient}<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Java side:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@Service\npublic class OutboundSender {\n\n    private final OutboundRepository repo;\n    private final SgcSmsClient client;\n    private final TransactionTemplate tx;\n\n    @Transactional\n    public Optional&lt;Long&gt; enqueue(String businessKey, String recipient, String body, String dltTemplateId) {\n        try {\n            return Optional.of(repo.insertPending(businessKey, recipient, body, dltTemplateId));\n        } catch (DataIntegrityViolationException dup) {\n            \/\/ Someone already enqueued this business event. Not an error.\n            return Optional.empty();\n        }\n    }\n\n    public void processOne(long id) {\n        \/\/ 1. Claim in its own short transaction. The HTTP call is NOT inside it.\n        OutboundMessage msg = tx.execute(status -&gt; repo.claim(id));\n        if (msg == null) {\n            return; \/\/ already claimed by another worker\n        }\n\n        SubmitResult result;\n        try {\n            result = client.send(msg.toRequest());\n        } catch (Throwable t) {\n            SendOutcome outcome = SendOutcome.classify(t);\n            if (outcome == SendOutcome.NOT_SUBMITTED) {\n                \/\/ Provably nothing happened. Returning to PENDING is legal here\n                \/\/ and ONLY here.\n                tx.executeWithoutResult(s -&gt; repo.releaseToPending(id, t.toString()));\n            } else {\n                \/\/ It may have been accepted and billed. This row is now the\n                \/\/ reconciler's problem, forever, or until a DLR resolves it.\n                tx.executeWithoutResult(s -&gt; repo.markUnknown(id, t.toString()));\n            }\n            return;\n        }\n\n        if (result.accepted()) {\n            tx.executeWithoutResult(s -&gt; repo.markSubmitted(id, result));\n        } else {\n            tx.executeWithoutResult(s -&gt; repo.markRejected(id, result));\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two Java-specific hazards to name explicitly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not put the HTTP call inside <code>@Transactional<\/code>.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>catch (Throwable t)<\/code> is correct here.<\/strong> This is one of the rare places where catching <code>Throwable<\/code> rather than <code>Exception<\/code> is the right call, because an <code>Error<\/code> such as <code>OutOfMemoryError<\/code> or a <code>NoClassDefFoundError<\/code> thrown during the send still leaves the row in a state where you do not know what happened, and leaving it <code>CLAIMED<\/code> forever is worse than recording <code>UNKNOWN<\/code>. Record and rethrow if you prefer, but do not let it escape without writing the state.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>releaseToPending<\/code> is called from exactly one branch. That constraint is the whole design. An <code>UNKNOWN<\/code> row never returns to <code>PENDING<\/code>, because every trip back to <code>PENDING<\/code> is a licence to send again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reconciler is a scheduled job that takes <code>UNKNOWN<\/code> rows older than a few minutes and asks the platform what actually happened, using the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/check-mis-by-transaction-id\/\">by-transaction status endpoint<\/a> when a <code>provider_id<\/code> was captured, or the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">delivery report endpoint<\/a> filtered by recipient and time window when it was not. Alert on the <em>age<\/em> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"batching-and-the-recipient-list\" class=\"wp-block-heading\">Batching and the recipient list<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The send endpoint accepts a comma-separated recipient list, and the same request can carry a JSON or XML body with an <code>sms<\/code> array of <code>{mobile:[...], msg:\"...\"}<\/code> objects, which allows a different body per recipient in one call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public List&lt;SubmitResult&gt; sendBatched(List&lt;String&gt; recipients, String body) {\n    int perRequest = props.maxRecipientsPerRequest();\n    List&lt;SubmitResult&gt; out = new ArrayList&lt;&gt;();\n    for (int i = 0; i &lt; recipients.size(); i += perRequest) {\n        List&lt;String&gt; slice = recipients.subList(i, Math.min(i + perRequest, recipients.size()));\n        out.add(send(SendRequest.of(slice, body)));\n    }\n    return out;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The critical caveat, and it is the reason the batch shape is not free: <strong>there is one top-level <code>transactionId<\/code> per request, not one per recipient.<\/strong> A five-hundred-recipient request returns a single identifier. Per-recipient correlation has to come afterwards from the delivery report endpoint, matched on <code>mobileNo<\/code>, <code>msgId<\/code> and <code>uuId<\/code>. If your outbound table has one row per recipient and you store the same <code>transactionId<\/code> on all five hundred of them, your reconciler cannot distinguish them and your billing dispute has no evidence.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The practical resolution: keep one row per recipient, store the shared <code>transactionId<\/code> as a batch identifier in a separate column, and populate the per-recipient <code>msgId<\/code> during reconciliation. Do not overload one column with both meanings.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"otp-in-spring-boot\" class=\"wp-block-heading\">OTP in Spring Boot<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">OTP has its own endpoint and its own semantics. <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">Generate<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/verify-otp\/\">verify<\/a> both POST to <code>https:\/\/unify.smsgateway.center\/SMSApi\/otp<\/code>, distinguished by <code>sendMethod<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Generate takes <code>sendMethod=generate<\/code>, <code>mobile<\/code>, <code>msgType<\/code>, a <code>msg<\/code> containing the literal <code>$otp$<\/code> placeholder, <code>medium=sms<\/code>, <code>codeType<\/code> of <code>num<\/code>, <code>alpha<\/code> or <code>alphanum<\/code>, <code>codeExpiry<\/code> in seconds with a default of 300, <code>codeLength<\/code>, <code>senderid<\/code> and <code>output<\/code>. Success returns <code>statusCode<\/code> 300, along with <code>createTime<\/code>, <code>expiryTime<\/code> and <code>retryAfter<\/code>. Verify takes <code>sendMethod=verify<\/code>, <code>mobile<\/code>, <code>otp<\/code> and <code>output<\/code>, and success returns <code>statusCode<\/code> 301.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Java-relevant detail: <strong><code>createTime<\/code>, <code>expiryTime<\/code> and <code>retryAfter<\/code> are millisecond epochs<\/strong>, not durations and not ISO strings. Convert them at the boundary and never let a raw long escape into your domain.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public record OtpChallenge(String mobile, Instant createdAt, Instant expiresAt, Instant retryAfter) {\n\n    static OtpChallenge from(String mobile, JsonNode n) {\n        return new OtpChallenge(\n                mobile,\n                instant(n, \"createTime\"),\n                instant(n, \"expiryTime\"),\n                instant(n, \"retryAfter\"));\n    }\n\n    private static Instant instant(JsonNode n, String field) {\n        JsonNode v = n.get(field);\n        if (v == null || v.isNull()) return null;\n        \/\/ Documented as a millisecond epoch. Guard against a string form.\n        long millis = v.isNumber() ? v.asLong() : Long.parseLong(v.asText().trim());\n        return Instant.ofEpochMilli(millis);\n    }\n\n    public Duration retryIn(Clock clock) {\n        return retryAfter == null ? Duration.ZERO\n                : Duration.between(clock.instant(), retryAfter).isNegative()\n                    ? Duration.ZERO\n                    : Duration.between(clock.instant(), retryAfter);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>retryIn(Clock)<\/code> takes a <code>Clock<\/code> rather than calling <code>Instant.now()<\/code>. 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 <code>Clock.systemUTC()<\/code> as a bean, use <code>Clock.fixed<\/code> in tests, and you can assert the boundary behaviour at the exact millisecond.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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. <strong>Shed OTP load, do not buffer it.<\/strong> Return an error to the user immediately and let them retry a request that will actually be fast.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s <code>retryAfter<\/code> 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&#8217;s cooldown from <code>retryAfter<\/code>, not from a hardcoded constant.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"whatsapp-from-the-same-codebase\" class=\"wp-block-heading\">WhatsApp from the same codebase<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-whatsapp-business-message\/\">WhatsApp send endpoint<\/a> is <code>POST https:\/\/unify.smsgateway.center\/WAApi\/send<\/code>, POST only, <code>application\/x-www-form-urlencoded<\/code>, with required <code>userid<\/code>, <code>password<\/code>, <code>msg<\/code>, <code>wabaNumber<\/code>, <code>mobile<\/code> in international format, <code>sendMethod<\/code>, <code>msgType<\/code> of <code>text<\/code> or <code>media<\/code>, <code>templateName<\/code> and <code>output<\/code>. Optional parameters include <code>header<\/code>, <code>footer<\/code>, <code>scheduletime<\/code> in <code>YYYY-MM-DD HH:MM<\/code> form, <code>buttonsPayload<\/code> as JSON, and <code>buttonUrlPath<\/code>. For media, <code>mediaId<\/code> or <code>mediaUrl<\/code>, <code>mediaType<\/code> and <code>documentName<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documented behaviour worth building around: &#8220;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.&#8221; In other words, the presence or absence of <code>templateName<\/code> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public sealed interface WhatsAppBody permits TemplateBody, SessionBody {}\npublic record TemplateBody(String templateName, String rendered) implements WhatsAppBody {}\npublic record SessionBody(String rendered) implements WhatsAppBody {}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the format differences you must normalise. <code>scheduletime<\/code> is lowercase <code>t<\/code> here and <code>YYYY-MM-DD HH:MM<\/code> without seconds, whereas the SMS <code>scheduleTime<\/code> is camelCase and carries seconds. That is exactly the kind of difference a <code>DateTimeFormatter<\/code> constant per channel handles and a shared one does not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The channel-selection question, meaning when SMS is the right answer and when WhatsApp is, is a separate decision covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-vs-whatsapp-business-api-when-to-use-each\/\">SMS API vs WhatsApp Business API<\/a>. If you are chaining channels, the <a href=\"https:\/\/www.smsgatewaycenter.com\/multi-channel-fallback\/\">multi-channel fallback<\/a> product supports narrowing the trigger to specific delivery error codes rather than falling through on any failure.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"delivery-reports-the-webhook-receiver\" class=\"wp-block-heading\">Delivery reports: the webhook receiver<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Register a webhook by POSTing to <code>https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code> with <code>smswebhook<\/code> set to your HTTPS URL and <code>smswebhookrate<\/code>, documented as &#8220;DLR TPS. AT what TPS rate DLR should be forwarded to. Default is 10.&#8221; 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Setup and panel configuration are covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">Real-Time DLR Webhooks<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/setup-webhook-sms-whatsapp-dlr\/\">webhook setup knowledge base entry<\/a>. The Java receiver is below.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@RestController\n@RequestMapping(\"\/webhooks\/dlr\")\npublic class DlrController {\n\n    private final byte&#91;] secret;\n    private final DlrIngestService ingest;\n\n    @PostMapping\n    public ResponseEntity&lt;Void&gt; receive(\n            @RequestBody byte&#91;] raw,                     \/\/ RAW BYTES. Not a POJO.\n            @RequestHeader(value = \"X-Signature\", required = false) String signature) {\n\n        if (secret.length &gt; 0) {\n            if (signature == null || !verify(raw, signature)) {\n                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();\n            }\n        }\n\n        \/\/ Persist first, parse later, acknowledge fast.\n        ingest.stageRaw(raw);\n        return ResponseEntity.ok().build();\n    }\n\n    private boolean verify(byte&#91;] raw, String signature) {\n        try {\n            Mac mac = Mac.getInstance(\"HmacSHA256\");\n            mac.init(new SecretKeySpec(secret, \"HmacSHA256\"));\n            byte&#91;] expected = mac.doFinal(raw);\n            byte&#91;] provided = HexFormat.of().parseHex(signature.trim());\n            \/\/ Constant time. Arrays.equals is not.\n            return MessageDigest.isEqual(expected, provided);\n        } catch (Exception e) {\n            return false;\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four things here are load-bearing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>@RequestBody byte[]<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>MessageDigest.isEqual<\/code> rather than <code>Arrays.equals<\/code> or <code>String.equals<\/code>. It is the constant-time comparison in the JDK. A timing side channel on a webhook signature is a real, if unglamorous, vulnerability.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A configured content-type filter can eat the body. If the payload arrives <code>application\/x-www-form-urlencoded<\/code> and your application has a global JSON converter or a filter that consumes the input stream, <code>raw<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The outbound push payload schema is not publicly documented.<\/strong> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"delivery-reports-the-poller\" class=\"wp-block-heading\">Delivery reports: the poller<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, <code>fromdate<\/code>, <code>todate<\/code>, <code>pageLimit<\/code>, and optionally <code>mobileNo<\/code>, <code>uuId<\/code> or <code>startCursor<\/code>. Each row in <code>reports_dlrList<\/code> carries <code>msgId<\/code>, <code>mobileNo<\/code>, <code>uuId<\/code>, <code>status<\/code>, <code>cause<\/code>, <code>cost<\/code>, <code>length<\/code>, <code>submitTime<\/code>, <code>deliveryTime<\/code>, <code>dltTemplateId<\/code>, <code>globalErrorCode<\/code>, <code>network<\/code>, <code>senderName<\/code> and <code>cursorId<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a read endpoint, so it is safe to retry at the transport level, unlike the send path. Use the read retry template.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@Component\npublic class DlrPoller {\n\n    private final RetryTemplate readRetry;\n\n    public void pollWindow(LocalDate from, LocalDate to) {\n        String cursor = null;\n        do {\n            final String c = cursor;\n            DlrPage page = readRetry.execute(ctx -&gt; fetchPage(from, to, c, 500));\n            for (DlrRow row : page.rows()) {\n                \/\/ BigDecimal, never double. This value ends up in a cost report.\n                BigDecimal cost = new BigDecimal(\n                        row.cost() == null || row.cost().isBlank() ? \"0\" : row.cost());\n                reconcile(row, cost);\n            }\n            cursor = page.nextCursor();\n        } while (cursor != null);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>deliveryTime<\/code> earlier than a <code>submitTime<\/code> is clock skew between systems, and recording it as a negative duration poisons a latency histogram permanently.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want the reporting terminology straight first, the knowledge base distinguishes the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-the-difference-between-sms-delivery-report-and-sms-delivery-summary\/\">delivery report from the delivery summary<\/a> and explains <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/dlr-status-meaning\/\">what the DLR statuses mean<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"resilience4j-rate-limiter-bulkhead-and-circuit-breaker\" class=\"wp-block-heading\">Resilience4j: rate limiter, bulkhead and circuit breaker<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three mechanisms, commonly confused, doing three different jobs.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Mechanism<\/th><th>Constrains<\/th><th>Use it for<\/th><\/tr><\/thead><tbody><tr><td>Rate limiter<\/td><td>Operations per unit time<\/td><td>Staying under an account throughput allowance<\/td><\/tr><tr><td>Bulkhead<\/td><td>Concurrent operations in flight<\/td><td>Protecting your own connection and thread pools<\/td><\/tr><tr><td>Circuit breaker<\/td><td>Calls after a failure threshold<\/td><td>Stopping a doomed call quickly, with care<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<pre class=\"wp-block-code\"><code>resilience4j:\n  ratelimiter:\n    instances:\n      sgcSend:\n        limitForPeriod: 20\n        limitRefreshPeriod: 1s\n        timeoutDuration: 2s      # how long a caller waits for a permit\n        registerHealthIndicator: true\n  bulkhead:\n    instances:\n      sgcSend:\n        maxConcurrentCalls: 25\n        maxWaitDuration: 1s\n  circuitbreaker:\n    instances:\n      sgcRead:                   # NOTE: read endpoints only\n        slidingWindowSize: 50\n        failureRateThreshold: 60\n        waitDurationInOpenState: 30s\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>@RateLimiter(name = \"sgcSend\")\n@Bulkhead(name = \"sgcSend\", type = Bulkhead.Type.SEMAPHORE)\npublic SubmitResult sendGoverned(SendRequest req) {\n    return client.send(req);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three warnings, all of which have bitten production systems.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The Resilience4j rate limiter is per JVM.<\/strong> 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&#8217;s <code>rate_limit<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not put a circuit breaker on the send path without thinking.<\/strong> The breaker&#8217;s job is to fail fast. On a read that is a clean win. On a send, &#8220;fail fast&#8221; 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The limiter must be outside the retry, not inside it.<\/strong> 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; <a href=\"https:\/\/resilience4j.readme.io\/\" target=\"_blank\" rel=\"noopener nofollow\">Resilience4j<\/a>&#8216;s default aspect order applies Bulkhead, then RateLimiter, then CircuitBreaker, then Retry, and you can override it with <code>resilience4j.*.aspect-order<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the provider-side view of how throttling works and what the platform does with excess traffic, see <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/api-rate-limiting-throttling-sms-services-best-practices\/\">API Rate Limiting and Throttling: Best Practices for SMS Services<\/a>. If you are on SMPP rather than HTTP, throughput is a window-size problem instead and is covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/smpp-rate-limits-throughput-tuning\/\">SMPP Rate Limits and Throughput Tuning<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is no publicly documented TPS ceiling on the HTTP API. Rather than guess one, discover yours: start conservative, increase the limiter&#8217;s <code>limitForPeriod<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"micrometer-instrumentation\" class=\"wp-block-heading\">Micrometer instrumentation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@Component\npublic class MessagingMetrics {\n\n    private final MeterRegistry registry;\n    private final Timer submitTimer;\n\n    public MessagingMetrics(MeterRegistry registry) {\n        this.registry = registry;\n        this.submitTimer = Timer.builder(\"messaging.submit.duration\")\n                .publishPercentileHistogram()\n                \/\/ Buckets must STRADDLE the response timeout. Default buckets\n                \/\/ top out well below 15s and give zero resolution at the cliff.\n                .minimumExpectedValue(Duration.ofMillis(50))\n                .maximumExpectedValue(Duration.ofSeconds(30))\n                .register(registry);\n    }\n\n    public void recordSubmit(Duration d, String channel, String outcome, String statusCode) {\n        submitTimer.record(d);\n        registry.counter(\"messaging.submit.total\",\n                \"channel\", channel,\n                \"outcome\", outcome,\n                \"status_code\", statusCode).increment();\n    }\n\n    \/** Billable volume is recipients x segments, not requests. *\/\n    public void recordUnits(int recipients, int segments, String encoding, String channel) {\n        registry.counter(\"messaging.units.total\",\n                \"encoding\", encoding,\n                \"channel\", channel).increment((double) recipients * segments);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The cardinality rule, stated plainly because it is the mistake that takes monitoring down during the incident you most need it: <strong>never use <code>mobileNo<\/code>, <code>transactionId<\/code>, <code>msgId<\/code> or message text as a metric tag.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Four gauges worth more than any counter, in rough order of predictive value:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Age of the oldest pending outbound row.<\/strong> 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.<\/li>\n\n\n\n<li><strong>Drain rate versus arrival rate.<\/strong> A sustained inversion is a queue that will not recover on its own.<\/li>\n\n\n\n<li><strong>Ambiguous outcome rate<\/strong>, meaning the proportion of sends classified <code>UNKNOWN<\/code>. This rises before the error rate does, because a platform under strain gets slow before it starts refusing.<\/li>\n\n\n\n<li><strong>Cost per delivered message<\/strong>, computed from the real <code>cost<\/code> field on the delivery report rather than from a blended rate. A rising value means retries or encoding drift.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/dashboard\/\">Dashboard Analytics endpoint<\/a> returns <code>balanceRaw<\/code> and <code>averagePerDay<\/code>, which is all the arithmetic requires.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, alert on ratios rather than absolutes almost everywhere, and compute the delivery ratio against <em>submitted<\/em>, 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"testing-with-junit-5-and-mockwebserver\" class=\"wp-block-heading\">Testing with JUnit 5 and MockWebServer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The reframe that reorders the whole test pyramid: <strong>billing fires at submission, not at delivery.<\/strong> The published billing terms state that &#8220;credits are non-refundable once SMS is successfully submitted to the operator&#8221; 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.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Layer<\/th><th>What it covers<\/th><th>Cost<\/th><th>Frequency<\/th><\/tr><\/thead><tbody><tr><td>Pure functions<\/td><td>Segment counting, DLT rendering, epoch conversion<\/td><td>Zero<\/td><td>Every commit<\/td><\/tr><tr><td>Transport mocks<\/td><td>Client behaviour, timeouts, parsing, classification<\/td><td>Zero<\/td><td>Every commit<\/td><\/tr><tr><td>Contract checks<\/td><td>Error code lists, response shapes<\/td><td>Zero, read-only endpoints<\/td><td>Nightly<\/td><\/tr><tr><td>Live smoke<\/td><td>Credentials work, a real message arrives<\/td><td>Real money<\/td><td>Pre-release only<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mock at the transport, not at your own class.<\/strong> A test that mocks <code>SgcSmsClient<\/code> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class SgcSmsClientTest {\n\n    private MockWebServer server;\n    private SgcSmsClient client;\n\n    @BeforeEach\n    void setUp() throws IOException {\n        server = new MockWebServer();\n        server.start();\n        client = buildClientPointingAt(server.url(\"\/\").toString(), Duration.ofSeconds(2));\n    }\n\n    @AfterEach\n    void tearDown() throws IOException { server.shutdown(); }\n\n    @Test\n    void sendsFormEncodedBodyWithCommaSeparatedRecipients() throws Exception {\n        server.enqueue(new MockResponse()\n                .setBody(\"{\\\"status\\\":\\\"success\\\",\\\"statusCode\\\":\\\"200\\\",\" +\n                         \"\\\"transactionId\\\":\\\"123\\\",\\\"reason\\\":\\\"success\\\",\\\"invalidMobile\\\":\\\"\\\"}\")\n                .addHeader(\"Content-Type\", \"application\/json\"));\n\n        client.send(SendRequest.of(List.of(\"919999999999\", \"919999999998\"), \"Hello\"));\n\n        RecordedRequest req = server.takeRequest();\n        assertThat(req.getHeader(\"Content-Type\"))\n                .startsWith(\"application\/x-www-form-urlencoded\");\n        String body = req.getBody().readUtf8();\n        assertThat(body).contains(\"mobile=919999999999%2C919999999998\");\n        assertThat(body).contains(\"sendMethod=quick\");\n        assertThat(body).doesNotContain(\"password=\");   \/\/ apiKey mode\n    }\n\n    @Test\n    void readTimeoutIsUnknownAndIsAttemptedExactlyOnce() {\n        server.enqueue(new MockResponse()\n                .setBodyDelay(5, TimeUnit.SECONDS)      \/\/ longer than the 2s timeout\n                .setBody(\"{}\"));\n\n        Throwable t = catchThrowable(() -&gt;\n                client.send(SendRequest.of(List.of(\"919999999999\"), \"Hello\")));\n\n        assertThat(SendOutcome.classify(t)).isEqualTo(SendOutcome.UNKNOWN);\n        \/\/ The assertion that actually matters: we did not send it twice.\n        assertThat(server.getRequestCount()).isEqualTo(1);\n    }\n\n    @Test\n    void acceptsQuotedAndUnquotedStatusCode() {\n        \/\/ SMSApi\/send quotes it, WAApi\/send does not. Both must parse.\n        assertThat(parse(\"{\\\"status\\\":\\\"success\\\",\\\"statusCode\\\":\\\"200\\\"}\").statusCode())\n                .isEqualTo(\"200\");\n        assertThat(parse(\"{\\\"status\\\":\\\"success\\\",\\\"statusCode\\\":200}\").statusCode())\n                .isEqualTo(\"200\");\n    }\n\n    @ParameterizedTest\n    @CsvSource({\n        \"160, TEXT, 1\", \"161, TEXT, 2\", \"306, TEXT, 2\", \"307, TEXT, 3\",\n        \"70,  UNICODE, 1\", \"71, UNICODE, 2\"\n    })\n    void segmentBoundaries(int length, String encoding, int expectedSegments) { \/* ... *\/ }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>assertThat(server.getRequestCount()).isEqualTo(1)<\/code> 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 <code>@Retryable<\/code> to the send method.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two more tests worth writing. A concurrency test that starts twenty threads on a <code>CyclicBarrier<\/code>, all attempting to enqueue the same <code>business_key<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For nightly contract checks, diff the codes your application handles against the live <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API response error code list<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">delivery error code list<\/a>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An account-level Sandbox exists, documented on the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">demo page<\/a>: &#8220;The demo environment is designed for testing. Some features use a Sandbox mode, where delivery is simulated for security.&#8221; 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is also <code>testMessage<\/code>, documented verbatim as &#8220;Enable true to test your message and messages wont be delivered when enabled true.&#8221; 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 <code>testMessage=true<\/code>, read the balance again, and record the answer in your own runbook.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"security-and-secret-handling\" class=\"wp-block-heading\">Security and secret handling<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Send credentials over TLS only, and keep your trust store current. If you hit handshake failures, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/tls13-upgrade-guide\/\">TLS 1.3 upgrade guide<\/a> covers the platform side.<\/li>\n\n\n\n<li>Prefer the <code>apiKey<\/code> header over <code>password<\/code> in the form body. It keeps the credential out of anything that logs request bodies, which in a default Spring Boot application with <code>WebClient<\/code> logging enabled at DEBUG is quite a lot.<\/li>\n\n\n\n<li>Override <code>toString<\/code> on any record or class holding a credential, as shown earlier.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>Never expose the OAuth <code>client_secret<\/code> in a mobile app package or in browser JavaScript. The documented flow requires the token exchange to happen server-side.<\/li>\n\n\n\n<li>Validate <code>state<\/code> on every OAuth callback. Spring Security does this for you; if you hand-roll the callback, do it yourself.<\/li>\n\n\n\n<li>Rotate API keys on a schedule and support two valid keys during the overlap, or rotation becomes an outage nobody wants to schedule.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"the-ten-step-build-order\" class=\"wp-block-heading\">The ten-step build order<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Create an API key, and send one message with cURL before writing any Java. Confirm the response shape with your own eyes.<\/li>\n\n\n\n<li>Add the <code>WebClient<\/code> bean with an explicit connect timeout and response timeout. Verify by pointing it at a deliberately slow endpoint.<\/li>\n\n\n\n<li>Write the <code>SendOutcome<\/code> classifier and its tests before you write the send client. The classifier is the design.<\/li>\n\n\n\n<li>Write the send client with a <code>MultiValueMap<\/code> body and raw-body capture. Test it against MockWebServer.<\/li>\n\n\n\n<li>Add the <code>outbound_message<\/code> table with the unique constraint on <code>business_key<\/code>. Prove the constraint with a concurrency test.<\/li>\n\n\n\n<li>Wire claim-before-send, with the HTTP call outside the transaction.<\/li>\n\n\n\n<li>Add the segment counter and the DLT renderer, with boundary tests and a dollar-sign test.<\/li>\n\n\n\n<li>Add <code>validateTemplate<\/code> as a CI gate over your template inventory.<\/li>\n\n\n\n<li>Add the DLR webhook receiver staging raw bytes, plus the poller as a backstop. Reconcile <code>UNKNOWN<\/code> rows.<\/li>\n\n\n\n<li>Add Micrometer metrics and alerts, starting with age of the oldest pending row. Then, and only then, turn up the rate limiter.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision matrix<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Situation<\/th><th>Choice<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Single-tenant service sending on its own account<\/td><td>API key in a header<\/td><td>Simplest, keeps the secret out of the body<\/td><\/tr><tr><td>Product sending on behalf of many customer accounts<\/td><td>OAuth 2.0 with PKCE<\/td><td>No API key pasting, revocable per customer<\/td><\/tr><tr><td>Blocking service, no reactive stack<\/td><td><code>WebClient<\/code> with <code>.block()<\/code><\/td><td>Centralised timeout configuration<\/td><\/tr><tr><td>Existing <code>RestTemplate<\/code> codebase<\/td><td>Keep it, but move to <code>HttpComponentsClientHttpRequestFactory<\/code><\/td><td>Two explicit timeouts instead of two infinities<\/td><\/tr><tr><td>High volume, same body to many recipients<\/td><td>Comma-separated batch<\/td><td>Fewer round trips, but one <code>transactionId<\/code> for all<\/td><\/tr><tr><td>Different body per recipient<\/td><td>JSON <code>sms<\/code> array on the same endpoint<\/td><td>Avoids one request per recipient<\/td><\/tr><tr><td>Transient upstream failure on a read<\/td><td>Spring Retry with backoff and jitter<\/td><td>Reads are idempotent<\/td><\/tr><tr><td>Ambiguous failure on a send<\/td><td>No retry, state <code>UNKNOWN<\/code>, reconcile<\/td><td>A retry here is a second charge<\/td><\/tr><tr><td>Over capacity, campaign traffic<\/td><td>Buffer<\/td><td>Late is acceptable<\/td><\/tr><tr><td>Over capacity, OTP traffic<\/td><td>Shed<\/td><td>Late guarantees a user resend and a second charge<\/td><\/tr><tr><td>Need per-recipient delivery status<\/td><td>Poll the report endpoint<\/td><td>The submit response carries one identifier for the batch<\/td><\/tr><tr><td>Need a nightly correctness signal<\/td><td>Diff the two error code lists<\/td><td>Free, read-only, catches contract drift<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"production-checklist\" class=\"wp-block-heading\">Production checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>HTTP layer<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Connect timeout set explicitly<\/li>\n\n\n\n<li>[ ] Response timeout set explicitly and lower than any caller timeout<\/li>\n\n\n\n<li>[ ] <code>pendingAcquireTimeout<\/code> set on the connection provider<\/li>\n\n\n\n<li>[ ] <code>maxInMemorySize<\/code> raised above the default for report responses<\/li>\n\n\n\n<li>[ ] Content type is <code>application\/x-www-form-urlencoded<\/code> on every write<\/li>\n\n\n\n<li>[ ] Values are not pre-encoded before <code>fromFormData<\/code><\/li>\n\n\n\n<li>[ ] Raw response body captured and stored on every send<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Parsing<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] <code>FAIL_ON_UNKNOWN_PROPERTIES<\/code> disabled<\/li>\n\n\n\n<li>[ ] <code>statusCode<\/code> accepts quoted and unquoted forms<\/li>\n\n\n\n<li>[ ] <code>transactionId<\/code> and <code>messageId<\/code> normalised to one field<\/li>\n\n\n\n<li>[ ] <code>reason<\/code> and <code>description<\/code> normalised to one field<\/li>\n\n\n\n<li>[ ] <code>@JsonProperty<\/code> on every field of the by-transaction report row<\/li>\n\n\n\n<li>[ ] <code>cost<\/code> parsed as <code>BigDecimal<\/code><\/li>\n\n\n\n<li>[ ] <code>invalidMobile<\/code> inspected on every multi-recipient send<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Correctness<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Segment counter iterates code points in the GSM branch<\/li>\n\n\n\n<li>[ ] Segment counter uses code units in the Unicode branch<\/li>\n\n\n\n<li>[ ] Boundary tests at 160, 161, 306, 307, 70 and 71<\/li>\n\n\n\n<li>[ ] DLT renderer is positional and does not use the regex engine<\/li>\n\n\n\n<li>[ ] Test with a value containing <code>$1<\/code>, <code>$&amp;<\/code> and a backslash<\/li>\n\n\n\n<li>[ ] Smart quotes, en dashes, ellipses and rupee signs normalised on input<\/li>\n\n\n\n<li>[ ] <code>validateTemplate<\/code> runs in CI over every template<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Idempotency and state<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Unique constraint on <code>business_key<\/code><\/li>\n\n\n\n<li>[ ] <code>business_key<\/code> excludes the channel<\/li>\n\n\n\n<li>[ ] HTTP call is outside the transaction<\/li>\n\n\n\n<li>[ ] <code>UNKNOWN<\/code> never transitions back to <code>PENDING<\/code><\/li>\n\n\n\n<li>[ ] Reconciler runs on a schedule and alerts on oldest unreconciled age<\/li>\n\n\n\n<li>[ ] One row per recipient, batch identifier in its own column<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Resilience<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Send retry template accepts only <code>NotSubmittedException<\/code><\/li>\n\n\n\n<li>[ ] Read retry template is separate and more permissive<\/li>\n\n\n\n<li>[ ] Full jitter on backoff<\/li>\n\n\n\n<li>[ ] Rate limiter understood to be per JVM<\/li>\n\n\n\n<li>[ ] Bulkhead sized below the connection pool<\/li>\n\n\n\n<li>[ ] No circuit breaker on the send path without a bounded buffer<\/li>\n\n\n\n<li>[ ] Aspect order verified so the limiter is outside the retry<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Inbound<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] <code>smswebhookrate<\/code> set to a rate the receiver sustains<\/li>\n\n\n\n<li>[ ] Webhook body received as <code>byte[]<\/code><\/li>\n\n\n\n<li>[ ] Signature compared with <code>MessageDigest.isEqual<\/code><\/li>\n\n\n\n<li>[ ] Persist before parse, acknowledge fast<\/li>\n\n\n\n<li>[ ] Zero-length body counted as a metric<\/li>\n\n\n\n<li>[ ] Poller runs as a backstop with cursor pagination<\/li>\n\n\n\n<li>[ ] Negative delivery latency discarded, not recorded<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Observability<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Submission and delivery metrics on separate axes<\/li>\n\n\n\n<li>[ ] Histogram buckets straddle the response timeout<\/li>\n\n\n\n<li>[ ] Volume counter increments by recipients times segments<\/li>\n\n\n\n<li>[ ] No recipient or transaction identifiers as metric tags<\/li>\n\n\n\n<li>[ ] Delivery ratio denominator is submitted<\/li>\n\n\n\n<li>[ ] Age of oldest pending row alerted<\/li>\n\n\n\n<li>[ ] Balance runway alerted against procurement lead time<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Security<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] <code>toString<\/code> overridden on credential-bearing types<\/li>\n\n\n\n<li>[ ] Redaction implemented as a serialisation filter<\/li>\n\n\n\n<li>[ ] Recipient numbers salted and hashed in logs<\/li>\n\n\n\n<li>[ ] OAuth client secret server-side only<\/li>\n\n\n\n<li>[ ] Key rotation supports an overlap window<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten mistakes<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Leaving the response timeout unset.<\/strong> The most common Spring Boot messaging defect. One upstream stall saturates a bounded pool and takes the whole application with it.<\/li>\n\n\n\n<li><strong>Adding <code>@Retryable<\/code> to the send method.<\/strong> One annotation, duplicate messages, duplicate charges, and a customer complaint that arrives before your alert does.<\/li>\n\n\n\n<li><strong>Posting JSON to the quick-send path.<\/strong> Presents as an authentication error. Costs an afternoon and sometimes a credential rotation that was never needed.<\/li>\n\n\n\n<li><strong>Using <code>String.replaceAll<\/code> for DLT variables.<\/strong> Breaks on any value containing a dollar sign, and breaks in a way that reads as a DLT problem.<\/li>\n\n\n\n<li><strong>Counting segments with <code>length()<\/code>.<\/strong> Overcounts in the GSM branch for extension characters and misclassifies encoding, so your cost model disagrees with the invoice.<\/li>\n\n\n\n<li><strong>Treating <code>status: success<\/code> as all recipients accepted.<\/strong> <code>invalidMobile<\/code> enumerates the rejected ones while the top-level status stays green.<\/li>\n\n\n\n<li><strong>Storing one <code>transactionId<\/code> on five hundred recipient rows.<\/strong> Destroys per-recipient reconciliation and leaves a billing dispute with no evidence.<\/li>\n\n\n\n<li><strong>Keeping the HTTP call inside <code>@Transactional<\/code>.<\/strong> Database connections held across network round trips, pool exhaustion at modest concurrency.<\/li>\n\n\n\n<li><strong>Mocking your own client class in tests.<\/strong> Proves your code calls your code. Catches none of the bugs this integration actually produces.<\/li>\n\n\n\n<li><strong>Assuming the Resilience4j rate limiter is global.<\/strong> Four instances, four times the rate, and a throttling incident that looks like a platform problem.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"what-this-article-deliberately-does-not-claim\" class=\"wp-block-heading\">What this article deliberately does not claim<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>There is no numeric TPS ceiling here.<\/strong> The HTTP API does not publish one. Discover yours empirically as described in the rate limiting section and record it as an account fact.<\/li>\n\n\n\n<li><strong>The webhook push payload schema is not named.<\/strong> It is not publicly documented. The receiver in this article stages raw bytes precisely so that you can derive the schema from real traffic.<\/li>\n\n\n\n<li><strong>Whether <code>testMessage=true<\/code> consumes credits is not stated.<\/strong> The documentation says only that messages will not be delivered. Measure it on your own account.<\/li>\n\n\n\n<li><strong>Whether Sandbox responses are byte-identical to live responses is not stated,<\/strong> nor is the list of which features are simulated, nor whether sandbox credentials differ.<\/li>\n\n\n\n<li><strong>No delivery percentage or SLO target is quoted.<\/strong> Delivery rates depend on route, operator, sender ID, template and time of day, and any single figure would be misleading.<\/li>\n\n\n\n<li><strong>The delivery report retention window length is not stated,<\/strong> which matters because it bounds how long an <code>UNKNOWN<\/code> row remains resolvable. Confirm it for your account before setting a reconciliation alert threshold.<\/li>\n\n\n\n<li><strong>Rate limits on the reporting and dashboard endpoints are not documented.<\/strong> Poll conservatively and observe.<\/li>\n\n\n\n<li><strong>Whether the <code>apiKey<\/code> header is case-sensitive is not settled by the docs,<\/strong> which show both <code>apiKey<\/code> and <code>apikey<\/code> in different places. Both appear in official samples. Pick one, test it, and file the inconsistency with support.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faq\" class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there an official Java or Spring Boot SDK?<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use <code>RestTemplate<\/code> or <code>WebClient<\/code>?<\/strong> <code>WebClient<\/code>, and call <code>.block()<\/code> if your application is not reactive. The reason is not performance, it is that <code>WebClient<\/code> centralises the timeout configuration in one bean where you can verify it, while <code>RestTemplate<\/code> scatters the same requirement across call sites.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my request fail authentication when the credentials are correct?<\/strong> Almost always because the body was sent as JSON rather than <code>application\/x-www-form-urlencoded<\/code> on the quick-send path. The parameters are never parsed, so <code>userid<\/code> and <code>password<\/code> appear absent. Check the content type before rotating anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send to multiple recipients in one call?<\/strong> Put a comma-separated list in <code>mobile<\/code>. The documentation describes &#8220;lists of comma-separated mobile numbers&#8221; and tolerates a space after the comma. For a different body per recipient, use the documented JSON body with an <code>sms<\/code> array of <code>{mobile:[...], msg:\"...\"}<\/code> objects.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does <code>duplicatecheck<\/code> protect me from sending the same message twice on a retry?<\/strong> 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&#8217;s unique constraint, not this parameter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What should I do when a send times out and I do not know if it went through?<\/strong> Nothing immediately. Record the row as <code>UNKNOWN<\/code>, never as <code>PENDING<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does Jackson throw on the WhatsApp response but not the SMS one?<\/strong> Because <code>statusCode<\/code> is a quoted string on <code>SMSApi\/send<\/code> and an unquoted number on <code>WAApi\/send<\/code>. Bind it with a lenient deserializer that accepts both, and normalise onto one internal type.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I map a response field called <code>Channel Name<\/code>?<\/strong> With an explicit <code>@JsonProperty(\"Channel Name\")<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I count SMS segments correctly in Java?<\/strong> Iterate by code point when testing GSM-7 membership, and count UTF-16 code units in the Unicode branch. <code>length()<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why is <code>String.replaceAll<\/code> unsafe for DLT variable substitution?<\/strong> Because the replacement argument is interpreted, not literal. A <code>$<\/code> followed by a digit is a capture group reference, so a customer name or product code containing a dollar sign either throws <code>IndexOutOfBoundsException<\/code> or substitutes the wrong text and fails template matching. Use <code>StringBuilder<\/code> and <code>indexOf<\/code>, or <code>Matcher.quoteReplacement<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use OAuth instead of an API key?<\/strong> Yes. The platform documents an OAuth 2.0 Authorization Code flow with PKCE, with <code>S256<\/code> only, an access token lasting one hour and a refresh token lasting thirty days. Register a client through support. Note that you still send <code>userid<\/code> on every SMS API request, set to the <code>username<\/code> returned in the token response.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does the Resilience4j rate limiter apply across all my instances?<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why should the HTTP call sit outside the database transaction?<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test this without spending money?<\/strong> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">Ready to wire this into your Spring Boot service? <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-api-key\/\">Create an account and generate an API key<\/a>, then try the flow end to end with the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">demo environment<\/a> before you point it at production traffic. If you are building a product that sends on behalf of other people&#8217;s accounts, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">contact support<\/a> to register an OAuth client.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>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.<\/p>\n","protected":false},"author":118,"featured_media":2711,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2016,1277,2148,2146,2149,2152,2150,1613,2147,2151],"class_list":["post-2710","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-delivery-reports","tag-dlt-compliance","tag-jackson","tag-java-sms-api","tag-oauth2-pkce","tag-otp-api","tag-resilience4j","tag-sms-api-integration","tag-spring-boot-sms","tag-webclient"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2710","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2710"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2710\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2711"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2710"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2710"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2710"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}