WhatsApp Operators DailyThe Blueticks DispatchSaturday, September 26, 2026
Productivity

How to Send WhatsApp Messages from Kotlin on Your Own Number (2026)

One authenticated POST from Kotlin, no third-party HTTP client. Then the parts that bite a Spring Boot app: the encoder that ruins the path, the null waMessageKey, and a coroutine retry that sends twice.

DRBy Daniel Roth · September 26, 2026 · 11 min read
How to Send WhatsApp Messages from Kotlin on Your Own Number (2026)

Your Spring Boot service already knows the order shipped, the invoice is overdue, the appointment is tomorrow. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.

Here is the plain version. A whatsapp api kotlin 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 is a WhatsApp API integration in Kotlin, really?

It is a single HTTP call from your own code to a number you already control. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the own-number model, not Meta's Cloud API, so no templates and no per-message fees apply.

The distinction changes your whole integration. On the Cloud API path you register a number with Meta, get it approved, write message templates, submit them for review, and pay per message. Meta deprecated conversation-based pricing on 1 July 2025, so the Cloud API now bills per message sent. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key.

Because Kotlin runs on the JVM, the concepts here match Java exactly, and the Java guide covers the same first send with java.net.http and no coroutines. This piece takes the idiomatic-Kotlin path instead: a suspend function, kotlinx.serialization data classes, one reused client, and Kotlin's null-safety mapped straight onto a nullable waMessageKey. The auth model and why your own number works this way is the subject of the own-number REST API guide, so this piece stays on the Kotlin mechanics.

Hold onto one thing from the start. Sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.

What do you need before your first send from Kotlin, Spring Boot, or Android?

Three things: a recent Kotlin and JDK toolchain (JDK 17 and Kotlin 1.9 are a safe floor), 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 single detail causes most first-attempt failures.

The pre-flight, in order:

  1. Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so they live in an environment variable or application.properties read through @Value, never in a committed file.
  2. Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
  3. Check what you do not need. No Meta Business verification, no message templates, no per-message billing. The first send needs no third-party HTTP client at all, only the JDK's java.net.http.

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 120363...@g.us for a group. The same first send in shell, Python and Node lives in the language-agnostic walkthrough if you want to sanity-check the request outside Kotlin first.

How do you send your first WhatsApp message from Kotlin with only java.net.http?

Build the body from a kotlinx.serialization @Serializable data class, set an Authorization: Bearer header, and POST to the escaped path with the JDK's built-in java.net.http.HttpClient. Run the blocking send on Dispatchers.IO inside a suspend function. Omit sendAt and it sends immediately. This is the send whatsapp message kotlin baseline, and it needs no third-party HTTP client.

import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

// ONE client for the whole process (see the next section). Never new per send.
val http: HttpClient = HttpClient.newBuilder()
    .connectTimeout(java.time.Duration.ofSeconds(5))
    .build()

val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }

@Serializable
data class SendBody(val type: String, val text: String, val sendAt: String? = null)

// The recipient is a PATH segment. URLEncoder applies form rules: '+' -> %2B
// (correct), but a space -> '+' (WRONG for a path). Fix the space case below.
fun pathSegment(recipient: String): String =
    URLEncoder.encode(recipient, StandardCharsets.UTF_8).replace("+", "%20")

suspend fun sendText(apiKey: String, recipient: String, text: String): Pair<Int, String> {
    val uri = URI.create("https://api.blueticks.co/v1/scheduled-messages/${pathSegment(recipient)}")
    // Build the JSON from the data class, never string interpolation: a quote
    // in a customer name would break a hand-concatenated payload.
    val body = json.encodeToString(SendBody(type = "text", text = text))

    val request = HttpRequest.newBuilder(uri)
        .header("Authorization", "Bearer $apiKey")
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", "invoice-4471-reminder")
        .timeout(java.time.Duration.ofSeconds(10)) // per-request read ceiling
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build()

    // The JDK send is blocking, so keep it off the calling dispatcher.
    val resp = withContext(Dispatchers.IO) {
        http.send(request, HttpResponse.BodyHandlers.ofString())
    }
    return resp.statusCode() to resp.body()
}

Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected with a 400. 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.

Two notes on the Kotlin side. The @Serializable data class needs the kotlinx.serialization compiler plugin, which is the one Kotlin-standard JSON dependency (not an HTTP client). And if you prefer the fully non-blocking form, client.sendAsync(...).await() works too, but await() on a CompletableFuture comes from the kotlinx-coroutines-jdk8 module, so it is not zero-dependency. The withContext(Dispatchers.IO) shape above stays on kotlinx-coroutines-core, which any project writing suspend functions already has.

A smartphone face-down on a calm wooden desk beside a hand and a closed notebook, a whatsapp api kotlin send confirmed

Ktor client or OkHttp? Pick one, then reuse a single instance

Reach for the Ktor client if you are already in a coroutine-first stack and want suspend calls natively, or OkHttp if you want a battle-tested pool and interceptors. The rule that matters either way, and the one thing a kotlin whatsapp integration most often gets wrong, is this: build ONE client for the process lifetime and reuse it. A fresh HttpClient { } or OkHttpClient() per send leaks the connection pool and dispatcher threads and pays a new TLS handshake every time.

The correct shape is a top-level val or, in Spring Boot, a @Bean created once with explicit timeouts:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*

// Build ONCE. In Spring Boot this is a @Bean; the container reuses it.
val wa: HttpClient = HttpClient(CIO) {
    install(HttpTimeout) {
        connectTimeoutMillis = 5_000
        requestTimeoutMillis = 10_000
    }
    expectSuccess = false // handle status codes yourself, don't throw on 4xx/5xx
}

The anti-pattern is quiet and common. Creating the client inside the send function, or inside a loop, looks harmless in a demo and starves you under load:

// WRONG: a new client (and pool) per send. TLS handshake every message.
suspend fun sendBad(text: String) {
    val client = HttpClient(CIO) { } // leaks pool + dispatcher on every call
    client.post("https://api.blueticks.co/v1/scheduled-messages/%2B15551234567") {
        setBody(text)
    }
    // client never closed -> resource leak
}

One coroutine note. If you use OkHttp or java.net.http's blocking send, wrap the call in withContext(Dispatchers.IO). Firing a blocking network call on the main or default dispatcher starves the pool that runs your CPU work. Ktor's suspend calls already yield correctly, so no wrapper is needed there.

How do you escape the recipient path without silently sending to the wrong number?

The leading + in an E.164 number must be percent-encoded to %2B because it sits in the URL path. Kotlin's reflex, URLEncoder.encode(phone, StandardCharsets.UTF_8), applies application/x-www-form-urlencoded rules: it does encode + to %2B correctly, but it turns a space into +, which is a query-string rule and wrong for a path segment.

For a bare phone number you would never notice, because a plain number has no space to mangle. The day a chat id or a display value carries a space, URLEncoder ships a + where the server expected %20, and the send resolves to the wrong recipient with no error. The fix is one chained call: let URLEncoder encode the literal + to %2B, then rewrite the only + that can survive (the one standing in for a space) to %20.

// After URLEncoder, the only remaining '+' is an encoded space, so
// replacing '+' -> %20 gives RFC 3986 path escaping. The '%2B' is untouched.
fun pathSegment(recipient: String): String =
    URLEncoder.encode(recipient, StandardCharsets.UTF_8).replace("+", "%20")

// +15551234567 -> %2B15551234567
// 120363...@g.us -> 120363...%40g.us

If you would rather not reason about the two encodings at all, build the URI with a proper builder that applies RFC 3986 path escaping directly. The point is the same: the recipient is a path segment, not a query parameter, so it needs path rules.

How do you model the response and survive a null waMessageKey with Kotlin's type system?

Model the envelope with kotlinx.serialization data classes and declare val waMessageKey: WaMessageKey? = null. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null, and only fills in later as an object with fromMe, remote, id, _serialized and participant. It is an object, never a bare key string. Kotlin's null-safety makes this a compile-time-guarded read.

This is the part that passes every test and breaks in production if you get the type wrong. Type waMessageKey as a non-null String and your first scheduled send throws at decode time. Type it as a nullable data class and the compiler forces you to reach through it safely.

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class WaMessageKey(
    val fromMe: Boolean = false,
    val remote: String? = null,
    val id: String? = null,
    val _serialized: String? = null,
    val participant: String? = null,
)

@Serializable
data class MessageData(
    val id: String,                       // 24-char hex queue id, your handle
    val status: String,                   // pending | confirmed | received | read | played | failed
    val waMessageKey: WaMessageKey? = null, // null until dispatch; nullable by design
)

@Serializable
data class Envelope(val success: Boolean = false, val data: MessageData? = null)

// ignoreUnknownKeys means a new server field never breaks decoding.
val json = Json { ignoreUnknownKeys = true }

fun handle(responseBody: String) {
    val env = json.decodeFromString<Envelope>(responseBody)
    val data = env.data ?: return          // a 2xx with no payload: don't touch it
    val messageId = data.id                // use this as your handle meanwhile
    val serialized = data.waMessageKey?._serialized // ?. guards the null object
    println("queued $messageId, wa key: $serialized")
}

The trimmed response on a scheduled send looks like this:

{ "success": true, "data": { "id": "6a1f...c2", "status": "pending", "waMessageKey": null } }

Read data.id, the 24-character hex queue id, as your handle in the meantime, and only touch data.waMessageKey?._serialized behind the ?.. Setting Json { ignoreUnknownKeys = true } matters here: the server adds response fields over time, and a strict decoder would throw on the first one it does not know. The status lifecycle a message moves through is pending, then confirmed, received, read, played, or failed.

How do you schedule a message for later and get sendAt right in Kotlin?

Add a camelCase sendAt field holding an RFC 3339 timestamp with an explicit offset, built with Instant.now().plusSeconds(...).toString(). An Instant always serialises with a Z (UTC) offset, so the moment is unambiguous. The accepted window is roughly 10 seconds to 365 days ahead; anything outside is rejected at validation with a 400.

import java.time.Instant

// Instant.toString() is RFC 3339 with a 'Z' offset. Unambiguous by construction.
val sendAt = Instant.now().plusSeconds(2 * 3600).toString() // 2026-09-26T14:30:00Z

val body = json.encodeToString(SendBody(type = "text", text = "Reminder", sendAt = sendAt))

The Kotlin-specific way this goes wrong is reaching for LocalDateTime or a system-zone ZonedDateTime. Both serialise your server's local offset, so the same code schedules a different instant on a laptop in Berlin than in a UTC container. Always use Instant, or kotlinx-datetime's Clock.System.now(), which is also UTC. A second trap: compute sendAt, let the job sit in a queue for nine seconds, and dispatch lands 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 deeper timezone hardening are a separate mechanism, covered in the scheduling-in-production guide. Do not rebuild that here.

Ready to wire this into your Spring Boot or Android app? Get a bt_live_ key and POST from the number you already own. No Meta Business verification, no message templates, no per-message fees. Point it at a live number and watch the first send land in seconds.

How do you make sends reliable from Spring Boot: coroutines, idempotency keys, and retries that never double-send?

Push the send off the request thread, retry only 429 and 5xx (never a 400), and carry a stable Idempotency-Key derived once from the business object so every retry reuses the identical value. Cap concurrency with a Semaphore instead of launching hundreds of coroutines wide. That combination is the whole game for a whatsapp spring boot integration that survives real traffic.

A tidy flat-lay with a closed laptop, a face-down phone, coffee, and one neat evenly-spaced row of identical pencils

import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.net.http.HttpResponse

// Cap in-flight sends so a 500-row batch does not leave in one angry burst.
val gate = Semaphore(permits = 4)

suspend fun sendReliably(apiKey: String, recipient: String, text: String, key: String) {
    var attempt = 0
    while (true) {
        attempt++
        val (code, _) = sendOnce(apiKey, recipient, text, key) // key is the SAME every attempt
        when {
            code in 200..299 -> return
            // Retry ONLY transient failures. A 400 is a bad body and fails
            // identically forever, so retrying it just burns attempts.
            (code == 429 || code >= 500) && attempt < 5 -> delay(1_000L * attempt)
            else -> {
                println("send $key giving up at status $code")
                return
            }
        }
    }
}

// Fan a batch out under structured concurrency, but bounded by the semaphore.
suspend fun sendBatch(apiKey: String, orders: List<Pair<String, String>>) = coroutineScope {
    orders.forEach { (phone, orderId) ->
        launch(Dispatchers.IO) {
            gate.withPermit {
                // Key derived ONCE from the business object. Every retry reuses it.
                // A per-attempt UUID.randomUUID() turns each retry into a NEW message.
                sendReliably(apiKey, phone, "Your order $orderId has shipped.", "order-$orderId-shipped")
            }
        }
    }
}

Two rules keep the retry safe. First, retry only 429, 5xx and transport errors, never a 400; a 400 is a bad body and will fail identically forever. Second, the Idempotency-Key is computed once from the order, ticket, or reminder (order-4471-shipped), so every attempt carries the identical value. Generate a UUID.randomUUID() per attempt and each retry becomes a new message. The key is capped at 64 characters and scoped per workspace: resend the same key with an identical body and the original response replays, returning the same 24-hex message id, so detect a replay by the id, not the status. A different body under the same key returns 409 Conflict, which means your key derivation has a bug.

The Semaphore is not only about throughput. A wide-open coroutine fan-out, five hundred launch calls firing sends as fast as they drain, is the burst pattern most likely to flag a number. Keep the permit count low and let the batch spread out. In Spring Boot specifically, prefer pushing the send onto a coroutine scope, an @Async method, or a message queue so the HTTP round-trip never blocks the request thread.

One production limit to design around: the free plan allows 5 requests per 6-hour window, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling. As one JVM operator running nightly shipping notifications put it, "the stable key was not about tidiness, it was the difference between one 'your order shipped' and three at 2am after a pod restart replayed the queue."

When should you use this instead of the WhatsApp Business (Cloud) API?

Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Use Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade is honest and runs both ways.

A calm split workspace with a small server rack on one side and an Android phone on the other, joined by one clean cable

The Kotlin and JVM reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients are Node projects, not JVM ones. Running your own from Spring Boot or Android means supervising a second runtime, a Node process and a browser profile, next to your JVM stack forever: a session store, a reconnect supervisor, and a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier, which is the argument the no-Meta-verification guide makes in full. The same one hosted endpoint answers your Spring Boot backend and your Android app with the identical POST.

A two-way whatsapp bot kotlin build is the same POST plus a webhook receiver to read inbound messages, so the send half you have here does not change. The same first send exists in Java, Go, C#, Ruby and PHP, and the bot-shaped variants live in the Python and Node.js guides.

One honest warning, because coroutines make it easy to get wrong. launch five hundred sends without a Semaphore and you fire them as fast as they drain. That burst is the pattern most likely to get a number flagged, whatever sent it, and WhatsApp's rules on automated and bulk messaging apply either way. Keep concurrency low, 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 Kotlin without adding an HTTP-client dependency?

Yes. The first-send example uses the JDK's built-in java.net.http.HttpClient, so no Ktor, OkHttp, or Retrofit is required. You do add the kotlinx.serialization plugin to build the JSON body safely, which is the Kotlin-standard JSON library, not an HTTP stack. Ktor and OkHttp add pooling and interceptor ergonomics, not capability.

Should I use Ktor, OkHttp, or java.net.http from a Kotlin backend?

Any of them, as long as you build one client and reuse it. Ktor suits a coroutine-first stack because its calls are natively suspend. OkHttp gives you a mature pool and interceptors. java.net.http needs no dependency at all. Whichever you pick, set explicit connect and read timeouts and run blocking sends on Dispatchers.IO.

Why does waMessageKey come back null, and how do I handle it in Kotlin?

On a scheduled send the engine has not dispatched the message yet, so waMessageKey is null and only fills in later as an object with fromMe, remote, id, _serialized, and participant. Model it as a nullable data class (WaMessageKey? = null) and read it with ?._serialized. Use the 24-hex id as your handle meanwhile. Typing it as a non-null String throws at decode time.

How do I stop a coroutine or Spring retry from sending the same WhatsApp message twice?

Send an Idempotency-Key header derived once from the business object (order-4471-shipped) so every retry reuses it. A replay returns the original response and the same 24-hex message id instead of sending again. A UUID.randomUUID() per attempt defeats the mechanism; the key must be stable across retries and is capped at 64 characters. Cap fan-out with a Semaphore so retries never stampede.

Do I need the Meta Cloud API to send WhatsApp from a Spring Boot or Android app?

No. The own-number path sends from a number you already control over WhatsApp Web or a hosted gateway, so there is no Meta Business verification, no message templates, and no per-message fees. Reach for the Cloud API and a Business Solution Provider only when you need approved marketing templates at broadcast scale or an Official Business Account badge.

Will sending from Kotlin 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 wide coroutine fan-out firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, cap concurrency with a Semaphore, spread batches over hours, and stop when someone asks. Consent is the sender's responsibility.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.