To send a text from Java, make an HTTPS POST to an SMS provider's REST endpoint with java.net.http.HttpClient, pass your API key in a header, and put the recipient and message in a JSON body. To learn whether the text was delivered and to receive replies, run a small HTTP endpoint that the provider calls (a webhook), and acknowledge each call fast. That covers the whole job of an sms java api integration. The rest of this post is the code, the failure handling, and the cost math.

We use the Ready REST API as the worked example because we make Ready and can vouch for every request shape shown. The patterns carry over to any provider: one HTTP client, idempotent retries, batched bulk sends, and a webhook receiver that hands work off to a queue. You need Java 11 or later. The examples use text blocks, so they assume Java 17.

What you need before the first request

  • An API key. Ready authenticates with an X-API-Key: <key> header. Keep the key in an environment variable or a secrets manager, never in source.
  • A number and a card on file. Ready requires a card on file to send. The first 20 texts are free, and they must be one-to-one sends. A campaign is paid from the first text. Use the free texts to test your integration before you pay for registration, because they end as soon as a registration is paid for.
  • A plan for 10DLC registration. An account can send up to 500 texts a month before it registers, at its normal per-text rate. After that it has to register. Registration has no setup fee. You pay the first month, $25, when you submit. After that the monthly cost is $25/mo for registration + $2 per number. Most approvals take 2 to 7 days. The full fee breakdown is in 10DLC cost: every fee explained.

If you're building a platform that texts on behalf of your own customers, each customer usually needs its own brand and campaign. 10DLC for SaaS platforms covers who registers what. Ready's sub-accounts (GET /v1/sub-accounts, POST /v1/sub-accounts) keep each customer's numbers and registration separate.

Send a text message from Java with java.net.http

The endpoint is POST https://api.tryready.com/v1/messages. The body takes to (E.164 format; a 10-digit US number is treated as +1), body, and consent_attested. The last field is your promise that the recipient agreed to receive texts.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class ReadyClient {
    private static final String BASE = "https://api.tryready.com";
    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private final String apiKey = System.getenv("READY_API_KEY");

    public HttpResponse<String> send(String to, String body, String idempotencyKey)
            throws Exception {
        String json = """
            {"to": "%s", "body": "%s", "consent_attested": true}
            """.formatted(esc(to), esc(body));

        HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/v1/messages"))
                .timeout(Duration.ofSeconds(15))
                .header("X-API-Key", apiKey)
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", idempotencyKey)
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        return http.send(req, HttpResponse.BodyHandlers.ofString());
    }

    static String esc(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
    }
}

Call it like this:

var sms = new ReadyClient();
var res = sms.send("+12125550199", "Your order #1042 has shipped.",
                   java.util.UUID.randomUUID().toString());
System.out.println(res.statusCode() + " " + res.body());

The hand-rolled esc method is only there to keep the example free of dependencies. In production, build the JSON with Jackson or Gson so you never ship a broken payload because a customer's name contained a tab.

There are three optional fields. from picks one of your numbers. media_url sends a picture as MMS. from_strategy accepts local, even or random. local sends from the number closest to the contact's area code. Local presence has a listed price of $0.0015 for each segment of a delivered text. Every number still sits on your registered 10DLC campaign, so local presence chooses among registered numbers rather than getting around registration.

Errors and retries without double-sending

Networks fail. If your request times out, you don't know whether the text went out. Retrying blindly can text a customer twice, and duplicate texts are how opt-outs happen.

The fix is the Idempotency-Key header. Generate one UUID per logical message, store it with the message record, and send the same key on every retry. Ready will not send twice for a retried request with the same key.

public HttpResponse<String> sendWithRetry(String to, String body, String key)
        throws Exception {
    int maxAttempts = 4;
    long backoffMs = 500;
    for (int attempt = 1; ; attempt++) {
        try {
            HttpResponse<String> res = send(to, body, key);
            int s = res.statusCode();
            boolean retryable = s == 429
                || (s == 409 && res.body().contains("idempotency_in_progress"));
            if (!retryable || attempt == maxAttempts) return res;
        } catch (java.io.IOException e) {
            if (attempt == maxAttempts) throw e;
        }
        long jitter = java.util.concurrent.ThreadLocalRandom.current().nextLong(250);
        Thread.sleep(backoffMs + jitter);
        backoffMs *= 2;
    }
}

The rules this follows:

  1. Retry with the same key on connection errors, timeouts, HTTP 429, and 409 idempotency_in_progress.
  2. Don't replay a 500 send_failed or a 503 with the same key. It was not sent and not charged, and the same key returns the stored error. Resend with a new key. After a 504 or a code ending in OUTCOME_UNKNOWN, check GET /v1/messages first, because the message may have gone out.
  3. Don't retry other 4xx responses. A malformed number or a bad key will fail the same way on attempt four. Log the response body and fix the input.
  4. Keep the key constant across attempts. A fresh UUID per attempt defeats the purpose.
  5. Back off exponentially with jitter so a fleet of workers doesn't hammer the API in lockstep after an outage.

Some refusals are not errors on your side. Ready refuses opted-out numbers, numbers on your Ban list and known landlines up front, and those refusals are not charged. Treat them as final and don't retry. For the exact status codes and error bodies, see the messages API reference. Don't rely on our summary here.

Sending to many numbers: batches of 100

For the same message to many recipients, use POST /v1/messages/bulk with to as an array. Each call accepts at most 100 recipients, so split larger lists:

public void sendBulk(java.util.List<String> numbers, String body) throws Exception {
    final int BATCH = 100;
    for (int i = 0; i < numbers.size(); i += BATCH) {
        var batch = numbers.subList(i, Math.min(i + BATCH, numbers.size()));
        String toArray = batch.stream()
                .map(n -> "\"" + esc(n) + "\"")
                .collect(java.util.stream.Collectors.joining(", "));
        String json = """
            {"to": [%s], "body": "%s", "consent_attested": true}
            """.formatted(toArray, esc(body));

        String key = batchKeyFor(i); // persist this so a crash-restart reuses it
        var res = postBulk(json, key);
        if (res.statusCode() / 100 != 2) {
            throw new IllegalStateException("batch " + i + " not accepted: "
                    + res.statusCode() + " " + res.body());
        }
        System.out.println("batch " + i + ": " + res.statusCode());
        // A 200 can still hold refusals: log data.failed, data.held and each data.results entry whose sent is false.
        System.out.println(res.body());
    }
}

// Same retry rules as sendWithRetry: 429 and 409 idempotency_in_progress, same key.
private HttpResponse<String> postBulk(String json, String key) throws Exception {
    long backoffMs = 500;
    for (int attempt = 1; ; attempt++) {
        HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/v1/messages/bulk"))
                .timeout(Duration.ofSeconds(15))
                .header("X-API-Key", apiKey)
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", key)
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();
        try {
            var res = http.send(req, HttpResponse.BodyHandlers.ofString());
            int s = res.statusCode();
            boolean retryable = s == 429
                || (s == 409 && res.body().contains("idempotency_in_progress"));
            if (!retryable || attempt == 4) return res;
        } catch (java.io.IOException e) {
            if (attempt == 4) throw e;
        }
        Thread.sleep(backoffMs);
        backoffMs *= 2;
    }
}

A batch Ready does not accept makes sendBulk throw with the batch number and the response, so a failed batch is never mistaken for a finished send. A 200 can still hold refusals: read data.failed and data.held, and log every entry in data.results whose sent is false with its error. After a 500, some texts in that batch may already have gone out, so check GET /v1/messages before you resend any of them. Resend a batch whole only after a 4xx or a 429.

Carriers meter throughput per campaign. A fresh standard campaign with no vetting gets roughly 1 to 4 segments per second, as a round approximation. At an assumed 2 segments per second, a 5,000-recipient, single-segment send takes about 42 minutes to fully leave the carrier side, even though your 50 API calls finish in seconds. Plan delivery-webhook volume accordingly. Whether brand vetting is worth it for your volume is its own question.

The full bulk reference is at docs.tryready.com/api/bulk-messages.

Receiving delivery and inbound webhooks in Java

Ready calls your endpoint when a message's delivery status changes and when a contact replies. The JDK ships a small HTTP server that is fine for a receiver behind a load balancer:

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.concurrent.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class WebhookReceiver {
    static final BlockingQueue<String> queue = new LinkedBlockingQueue<>();

    // "sha256=" + lowercase hex HMAC-SHA256 of the exact raw body.
    static String expectedSignature(String secret, byte[] raw) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return "sha256=" + HexFormat.of().formatHex(mac.doFinal(raw));
    }

    public static void main(String[] args) throws Exception {
        String secret = System.getenv("READY_WEBHOOK_SECRET"); // whsec_..., the whole value
        HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);

        server.createContext("/hooks/ready", exchange -> {
            if (!"POST".equals(exchange.getRequestMethod())) {
                exchange.sendResponseHeaders(405, -1);
                exchange.close();
                return;
            }
            byte[] raw = exchange.getRequestBody().readAllBytes();
            String got = exchange.getRequestHeaders().getFirst("X-Signature");
            if (got == null || !MessageDigest.isEqual(
                    got.getBytes(StandardCharsets.UTF_8),
                    expectedSignature(secret, raw).getBytes(StandardCharsets.UTF_8))) {
                exchange.sendResponseHeaders(401, -1);
                exchange.close();
                return;
            }
            queue.offer(new String(raw, StandardCharsets.UTF_8));
            exchange.sendResponseHeaders(200, -1);
            exchange.close();
        });

        server.setExecutor(Executors.newFixedThreadPool(4));
        server.start();

        // A worker drains the queue, parses the payload, updates your DB.
        Executors.newSingleThreadExecutor().submit(() -> {
            while (true) {
                String payload = queue.take();
                try {
                    handle(payload); // parse per the documented schema
                } catch (Exception e) {
                    // a throw here would end the worker and nobody would know
                    System.err.println("webhook handle failed, payload kept for retry: " + e);
                    // write payload to a dead-letter table
                }
            }
        });
    }
}

We deliberately don't show the payload fields here. Parse them from the schema in the Ready docs rather than from a guess in a blog post. The design rules apply regardless of the schema:

  • Acknowledge first, work later. Return 200 as soon as you've safely queued the body. Slow handlers cause timeouts, and timeouts cause redeliveries. Catch every handler exception and keep the payload (a dead-letter row), because an exception that escapes ends the worker thread and your app silently stops recording events.
  • Make handlers idempotent. Assume any event can arrive twice. Dedupe on the message identifier in the payload before you update state.
  • Don't assume order. A status update can arrive before your send call's own response is processed. Upsert by message ID.
  • Expect late deliveries. A text sent during the recipient's quiet hours is held and sent later, and it is charged only when it sends, if sending opens within about 6 hours; otherwise the API refuses it and you send it again once sending opens. Its delivery event can therefore arrive hours after your API call. That's correct behavior, not a stuck queue.
  • Verify X-Signature over the raw body, as the code above does, and serve only over HTTPS. Reject events with an old timestamp and drop repeated X-Ready-Event-Ids; see verifying the signature. Use a real queue (SQS, Kafka, a database table) instead of the in-memory one above, so events survive a restart.

Inbound replies also land in the Ready Inbox, and in GoHighLevel for connected accounts. When a contact texts STOP, Ready honors it automatically and blocks future sends to that number across campaigns. If you want to mirror opt-outs in your own database, or reconcile after webhook downtime, poll GET /v1/opt-outs or check a single number with GET /v1/opt-outs/:phone. That list holds only this account's opted-out contacts, so check GET /v1/opt-outs/:phone before another tool texts a number. GET /v1/messages, paged with before_id, works the same way for catching up on message history.

What it costs: segment math for Java senders

Ready bills per segment on one Standard rate: $0.0075/segment plus a $0.0045 carrier fee, with no volume tiers. Replies you receive are billed the same way as sends. That all-in figure excludes the compliance surcharge: scrubbing and landline validation are each $0.003 extra per segment when switched on for an account, and both are off by default.

Segment length matters more than most developers expect:

MessageSegments1,000 recipients on Standard
120 chars, plain text1$20 + 1,000 × $0.0045
200 chars, plain text (153 per part)2$40 + 2,000 × $0.0045
140 chars with one emoji (67 per part)3$60 + 3,000 × $0.0045

A single GSM-7 segment holds 160 characters. One emoji switches the message to unicode, where a single segment holds 70 characters and multipart segments hold 67. Ready swaps curly quotes, long dashes and ellipses for plain ones before sending, so text pasted from a CMS won't silently triple your bill. Emojis will, so count them in your templates.

To be straightforward about price: if per-segment cost is your only criterion, Twilio's rate of around $0.0083 per segment is lower than our Standard rate. Twilio also has an official Java SDK. What you get with Ready is self-serve 10DLC registration, quiet-hours holds, STOP handling, sub-accounts for each of your customers, and DNC/litigator scrubbing in the same API, so you don't build those yourself. The tradeoffs are laid out in our Twilio alternatives comparison.

Compliance your code should respect

Setting consent_attested: true records your attestation for each send: who sent, to how many, and when. It is not a per-contact consent record. Ready does not store the recipient's opt-in time, wording or page URL, so your own database has to keep those. Before a send, you can validate a number with GET /v1/validate/:phone and screen it with GET /v1/scrub/:phone. Treat these as risk reduction. They do not grant immunity, and compliance stays the sender's responsibility. Even an approved campaign can get filtered at the content level, so test your real templates. (A contact who signs up on a Ready-hosted opt-in page is the exception: Ready stores that sign-up, with the time, IP address and exact wording, and support@tryready.com can send you a copy.)

Wrapping up

A production Java SMS integration is four pieces: an HttpClient POST with your key in a header, an Idempotency-Key that stays constant across retries, bulk sends chunked into batches of 100, and a webhook receiver that queues events and returns 200 fast. Get those right and the provider underneath matters mostly for registration, compliance and price.

If you want to try the code above, sign up, add a card, and use your 20 free one-to-one texts to test sends and webhooks before you register.

FAQ

Do I need a library to send SMS from Java? No. java.net.http.HttpClient (Java 11+) handles the HTTPS requests. Add Jackson or Gson for JSON, and nothing else is required.

How do I avoid sending the same text twice when retrying? Generate one UUID per logical message, store it, and send it as the Idempotency-Key header on every attempt. Retry only on network errors, 429 and 409 idempotency_in_progress. A 500 send_failed or 503 needs a new key; after a 504, check GET /v1/messages first.

How many recipients can I send to per request? Up to 100 per call to POST /v1/messages/bulk. Split larger lists into batches, and give each batch its own persisted idempotency key.

Why did my delivery webhook arrive hours after I sent? Most likely quiet hours. Ready holds an API text sent outside the recipient's permitted local hours and sends it later, charging only when it actually goes out, if sending opens within about 6 hours. Otherwise the API refuses the text and nothing is queued.

Can I send before 10DLC registration is approved? Yes, up to 500 texts a month at your normal per-text rate. An account that bought its number before registering also sends on Ready's shared registration while its own registration is in review. Most approvals take 2 to 7 days.