Your Spring service already knows the invoice is overdue, the build broke, the shipment moved. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a handful of notifications a day.
Here is the plain version. A whatsapp api java integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own, linked as a device over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.
What do you need before Java can send a WhatsApp message?
Three things: a JDK 11 or newer, a WhatsApp number already linked to your Blueticks account, and a bt_live_ API key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That one detail causes most first-attempt failures in a java whatsapp api setup.
The pre-flight, in order:
- Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so they belong in an environment variable or your secrets manager, never checked into a repo. The own-number REST API guide covers the auth model in depth.
- Link a number. Blueticks drives your number, not a Meta-provisioned one. Already connected a phone in the app? You are done.
- Check your JDK. The whole guide runs on the standard library from Java 11 up, because
java.net.http.HttpClientships in the JDK since Java 11. No Maven, no Gradle, no third-party HTTP client required for the first send.
Now the trap. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 1234567890@g.us for a group.
How do you send your first WhatsApp message from Java with no dependencies?
Encode the + to %2B, set an Authorization: Bearer header, and POST a flat JSON body of {"type":"text","text":"..."} with the built-in HttpClient. Omit sendAt and it goes immediately. This is the send whatsapp message java baseline, and it needs nothing outside the JDK.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
String apiKey = System.getenv("BLUETICKS_API_KEY"); // bt_live_YOUR_KEY_HERE
String chatId = "+15551234567";
// The ONLY reserved character in an E.164 number is the leading '+'.
// Encode just that to %2B; do NOT reach for URLEncoder here (see below).
String segment = chatId.replace("+", "%2B"); // -> %2B15551234567
URI uri = URI.create("https://api.blueticks.co/v1/scheduled-messages/" + segment);
String json = """
{"type":"text","text":"Your invoice #4471 is due tomorrow."}""";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // connection phase only
.build();
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(20)) // whole request/response
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "invoice-4471-reminder")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id lives at data.id, never at the top level. The language-agnostic API walkthrough shows the same call in shell, Python and Node, and the PHP version is here.
Now the Java-specific footgun in that snippet. The obvious move for the path is URLEncoder.encode(), and it looks right: it turns + into %2B, so your E.164 send works and you never notice. But URLEncoder implements application/x-www-form-urlencoded, the form/query encoder, and its own docs state "the space character is converted into a plus sign +." That is correct for a query string and wrong for a path segment. The day a chat id carries a space, or you feed it a value shaped differently from a bare phone number, URLEncoder ships a + where the server expected %20 and the send resolves to the wrong recipient. Encode the one reserved character yourself, as above, or use a real URI builder (OkHttp's HttpUrl and Spring's UriComponentsBuilder both encode path segments correctly, shown next).
The other one: HttpClient.send() throws on a real network failure. A thrown IOException means no HTTP response ever arrived, a different situation from a 4xx or 5xx status, and the error table below turns on exactly that distinction.
Should you use OkHttp or Spring's RestClient instead?
Use the raw JDK client when you want zero dependencies. Reach for OkHttp when you want connection pooling and an interceptor chain, and Spring's RestClient when you are already inside a Spring app. All three java send whatsapp message paths hit the identical endpoint. The choice that actually matters is timeouts.
The JDK HttpClient has a sharp default. Its request javadoc is explicit: "the effect of not setting a timeout is the same as setting an infinite Duration, i.e. block forever" (HttpRequest.Builder). Set connectTimeout on the client and timeout on the request, both, every time. A thread blocked forever on a socket is how one slow dependency drains a pool, the Java analogue of a queue worker parked on an open connection.
OkHttp gives you separate connect, read and write timeouts and encodes the path for you:
import okhttp3.*;
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(20))
.build();
HttpUrl url = new HttpUrl.Builder()
.scheme("https").host("api.blueticks.co")
.addPathSegment("v1").addPathSegment("scheduled-messages")
.addPathSegment("+15551234567") // encoded correctly as a segment
.build();
RequestBody body = RequestBody.create(
"{\"type\":\"text\",\"text\":\"Your table is ready.\"}",
MediaType.get("application/json"));
Request request = new Request.Builder()
.url(url)
.header("Authorization", "Bearer " + System.getenv("BLUETICKS_API_KEY"))
.header("Idempotency-Key", "booking-8812-ready")
.post(body)
.build();
try (Response response = client.newCall(request).execute()) {
System.out.println(response.code() + " " + response.body().string());
}
Note addPathSegment("+15551234567") handles the + for you, which is the whole reason a URI builder beats string concatenation for a whatsapp business api java client. In a Spring app, RestClient does the same with UriComponentsBuilder, and you inject the key from configuration rather than reading System.getenv inline. Whichever client you pick, the request shape and the response envelope are identical.
How do you parse the response without a brittle model?
Read from the data wrapper, and model only the fields you use, defensively. A successful send returns {"success":true,"data":{...}} with id, status and waMessageKey under data. Two things bite a Java client: data can be absent on a 2xx that has nothing to return, and waMessageKey is an object, not a String.
With Jackson, annotate the DTO with @JsonIgnoreProperties(ignoreUnknown = true) so an added field never throws, and type waMessageKey as an object that is null until dispatch:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
@JsonIgnoreProperties(ignoreUnknown = true)
record Envelope(boolean success, Data data) {}
@JsonIgnoreProperties(ignoreUnknown = true)
record Data(String id, String status, WaMessageKey waMessageKey) {}
@JsonIgnoreProperties(ignoreUnknown = true)
record WaMessageKey(boolean fromMe, String remote, String id, String _serialized) {}
ObjectMapper mapper = new ObjectMapper();
Envelope env = mapper.readValue(response.body(), Envelope.class);
if (env.data() == null) {
// 2xx with no payload — nothing to read, do not dereference data.
return;
}
String messageId = env.data().id(); // 24-char hex queue id
Modelling waMessageKey as a String is the mistake that surfaces in production, not in tests: it is null on the response to a scheduled send because the engine has not dispatched yet, so your test passes and the field quietly fills in later as an object. Treat it as nullable and read id as your handle meanwhile.
How do you schedule a message for later and get sendAt right in Java?
Add a sendAt field holding an RFC 3339 timestamp with an explicit offset. Build it with OffsetDateTime or ZonedDateTime bound to a named zone, then format with DateTimeFormatter.ISO_OFFSET_DATE_TIME. The accepted window is roughly 10 seconds to 365 days ahead. Anything outside that is rejected at validation.
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
String sendAt = ZonedDateTime
.of(2026, 9, 1, 9, 0, 0, 0, ZoneId.of("Asia/Jerusalem"))
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); // 2026-09-01T09:00:00+03:00
String json = """
{"type":"text","text":"Reminder","sendAt":"%s"}""".formatted(sendAt);
The Java-specific way this goes wrong is the system-default zone. LocalDateTime carries no offset, and ZoneId.systemDefault() reads the host's default: your laptop is +03:00, the container runs UTC, and the same code schedules two different moments depending on where it executes. Always name the zone explicitly with ZoneId.of(...). A second one: compute sendAt, sit in a queue for nine seconds, and dispatch arrives already inside the floor with 400 sendAt must be at least 10 seconds in the future. Compute it at send time or leave a minute of headroom.
Recurring cadences and the production hardening around timezones are a separate mechanism, covered in the Python scheduling guide. Do not rebuild that here.
How do you send from Spring Boot without blocking the request?
Never call the API inline in a controller. Hand the send to an async worker or a queue, bind the key through configuration, and let the worker own retries with a stable Idempotency-Key derived from the business object so a retry never double-sends. That last part is the whole game for a whatsapp api integration java that survives contact with real traffic.
@Service
class WhatsAppSender {
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
@Value("${blueticks.api-key}") // bound from config, not a literal
private String apiKey;
@Async
@Retryable(retryFor = IOException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 10_000, multiplier = 3))
public void send(String chatId, String text, String idempotencyKey) throws IOException, InterruptedException {
String segment = chatId.replace("+", "%2B");
HttpRequest request = HttpRequest.newBuilder(
URI.create("https://api.blueticks.co/v1/scheduled-messages/" + segment))
.timeout(Duration.ofSeconds(20))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey) // SAME key on every retry
.POST(HttpRequest.BodyPublishers.ofString(
"{\"type\":\"text\",\"text\":\"" + text + "\"}"))
.build();
HttpResponse<String> res = client.send(request, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 429 || res.statusCode() >= 500) {
throw new IOException("retryable: " + res.statusCode()); // let @Retryable back off
}
}
}
The idempotency key is a method argument on purpose: derive it once from the order, ticket or reminder (order-4471-shipped) at dispatch, so all four attempts carry the identical value. Compute it inside the method from Instant.now() and every retry becomes a fresh message. Whether you use Spring Retry as shown or Resilience4j, the rule is the same: retry only IOException, 429 and 5xx, never a 400, and always with the stable key.
Ready to wire this into your app? Get a
bt_live_key and drop the queued Spring service above into your app. Your own number, your own contacts, no Meta Business verification and no per-message template fees. Point it at a live number and watch the first send land in seconds.

Event-triggered sends, the ones fired from a webhook or a domain event rather than a schedule, belong to the event automation guide. This section is only the non-blocking mechanics: hand off, retry with a stable key, done.
What errors does a Java client hit, and what should it do with each?
Four HTTP statuses carry the decisions, and below them sit the non-HTTP failures a Java client actually hits: a thrown IOException with no status at all, and a JVM trust-store surprise. The rule that runs through all of it: an HTTP status means the server answered and told you something specific; a thrown exception means no response ever arrived.
| Status | Meaning | What your client should do |
|---|---|---|
400 | Body or sendAt failed validation | Fix, never retry. Read error.message |
409 | Same Idempotency-Key, different body | Bug in your key derivation |
429 | Rate limited | Back off, retry with the same key |
5xx | Server side | Retry with backoff and the same key |
Two limits shape that. The free plan allows 5 requests per 6-hour slot, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second; any subscription removes the ceiling. And Idempotency-Key is capped at 64 characters and scoped per workspace: reuse it with an identical body and the original response replays, returning the same message id, so detect a replay by the id, not the status. A 409 means you reused a key with a different body, which is a bug in how you derive the key.
An IOException is not a 5xx. HttpClient.send() throws HttpTimeoutException (a subclass of IOException) when the response misses your timeout, and HttpConnectTimeoutException when the connection phase misses connectTimeout. Both mean no response ever existed, so you cannot know whether the request landed, which is exactly why the stable idempotency key makes the retry safe. Code that only branches on statusCode() treats a timeout as an unhandled crash.
PKIX path building failed. On a locked-down or older JVM the first HTTPS call can throw sun.security.validator.ValidatorException: PKIX path building failed, which reads like an API outage and is not. It is the JVM's trust store missing the CA that signed the endpoint's certificate. The fix is to update the JDK or import the CA into the cacerts trust store with keytool. Do not disable TLS verification, which turns a config problem into a security hole.
How do you confirm the message actually arrived?
Not from the 2xx. Take the id from data.id and GET /v1/scheduled-messages/{id}, then read the status ladder: pending, confirmed, received, read, played, or failed. A create response only means the queue accepted the message.
The ladder in one line each:
pending- accepted and waiting to dispatchconfirmed- WhatsApp itself took the messagereceived- delivered, the double grey tickread- opened, the double blue tickplayed- a voice note was playedfailed- carries afailureReason
The field to watch is waMessageKey. It is null on the response to a scheduled send and fills in as an object once the engine dispatches, which is why confirmed and a non-null waMessageKey arrive together. Your handle in the meantime is the 24-character hex id: you can GET it, PATCH it before dispatch, or DELETE it to cancel.
Polling is fine for one message and stops being fine at volume. Past that, register a webhook and receive status changes instead. Registration, payload verification and duplicate handling all live in the delivery-status webhooks guide, deliberately not repeated here.

Where Blueticks fits for a Java team, and where a BSP is the better call
A hosted REST engine fits JVM shops for one structural reason: the maintained WhatsApp Web clients are Node projects. Running your own from Java means supervising a second runtime, a Node process and a browser profile, next to your JVM app forever. For approved template broadcast at scale or a multi-agent shared inbox, a Business Solution Provider is genuinely the better product. We are not the only WhatsApp API on your own number, and this is where the honest trade sits.
That is the argument for a whatsapp business api java integration specifically. Whatsapp-web.js and Baileys are both Node, so "build it yourself" from Java means a session store, a reconnect supervisor and a pager rotation for something that is not your product. The Node bot guide shows what that maintenance looks like.
Where a BSP wins is not close. Approved marketing templates to 50,000 people, an Official Business Account badge, ten agents on one inbox: that is what the Cloud API and its partners are built for. Our read on when the Business API is worth it says the same. The trade is the number itself: one registered on the Cloud API stops behaving like a normal WhatsApp number.
The middle ground is the common Java case. Transactional volume, real conversations, replies that go to a human, a number your customers already have saved. One honest warning, because a queue makes it easy to get wrong: fan 500 jobs onto your executor and you fire 500 sends as fast as the pool drains. That burst is the pattern that gets numbers flagged, whatever sent it, and WhatsApp's policy on automated and bulk messaging applies either way. Throttle the executor, spread a batch over hours, and remember that consent is the sender's responsibility. Nobody can guarantee a number will never be actioned.
FAQ
Can I send a WhatsApp message from Java without Maven or a build tool?
Yes. The HttpClient example above needs nothing beyond the JDK, because java.net.http ships in the standard library from Java 11 onward. Maven or Gradle only matter if you want OkHttp, Jackson or Spring, and none of them adds capability, only ergonomics.
Which Java HTTP client should I use for the WhatsApp API?
The built-in HttpClient for zero dependencies, OkHttp for connection pooling and interceptors, or Spring's RestClient when you are already in a Spring app. All three call the identical endpoint. Whichever you pick, set both a connect timeout and a request timeout, because the JDK client blocks forever by default.
How do I stop a Java retry from sending the same WhatsApp message twice?
Send an Idempotency-Key header, derived once from the business object (order-4471-shipped) and passed into the worker so every retry reuses it. A replay returns the original response and the same message id instead of sending again. Compute the key from Instant.now() inside the retry and each attempt becomes a fresh message.
Why does my sendAt schedule the message at the wrong time?
Almost always the system-default zone. LocalDateTime has no offset and ZoneId.systemDefault() reads the host, so the same code schedules differently on a laptop versus a UTC container. Build the timestamp with ZonedDateTime bound to an explicit ZoneId.of(...) and format with DateTimeFormatter.ISO_OFFSET_DATE_TIME.
Will sending from Java get my WhatsApp number banned?
Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a loop firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, spread batches over hours, and stop when someone asks you to. Consent is the sender's responsibility.



