Send and receive SMS in Java with a REST API
Published and updated
You need an API key and one Android phone with a SIM registered as a device. From Java you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.
Prerequisites
- A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
- An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
- Java 17 or newer. The samples use java.net.http and the built in HTTP server, and run as single files with java Send.java. In an application add Jackson or Gson to parse the JSON responses instead of the string handling shown here.
Send an SMS
One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Send {
static final String BASE_URL = System.getenv().getOrDefault("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1");
static final String API_KEY = System.getenv("TEXTBEE_API_KEY");
public static void main(String[] args) throws Exception {
String body = "{\"recipients\": [\"+12015550123\"], \"message\": \"Hello from textbee\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/gateway/send-sms"))
.header("x-api-key", API_KEY)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
}
System.out.println(response.body()); // parse with Jackson or Gson in an application
}
}Receive SMS with a webhook
textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Webhook {
static final byte[] SECRET = System.getenv("TEXTBEE_WEBHOOK_SECRET").getBytes();
static final Set<String> seen = ConcurrentHashMap.newKeySet(); // use your database in production
static String field(String json, String name) {
Matcher matcher = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
return matcher.find() ? matcher.group(1) : "";
}
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "3000"));
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", exchange -> {
byte[] rawBody = exchange.getRequestBody().readAllBytes();
String expected;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
} catch (Exception error) {
throw new IllegalStateException(error);
}
String signature = exchange.getRequestHeaders().getFirst("X-Signature");
if (signature == null || !MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) {
exchange.sendResponseHeaders(401, -1);
return;
}
String json = new String(rawBody);
if (seen.add(field(json, "idempotencyKey")) && field(json, "webhookEvent").equals("MESSAGE_RECEIVED")) {
System.out.println(field(json, "sender") + ": " + field(json, "message"));
}
exchange.sendResponseHeaders(200, -1);
});
server.start();
}
}Poll for new messages with a cursor
The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Poll {
static final String BASE_URL = System.getenv().getOrDefault("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1");
static final String API_KEY = System.getenv("TEXTBEE_API_KEY");
static final HttpClient client = HttpClient.newHttpClient();
static String fetchPage(String query) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/gateway/messages?" + query))
.header("x-api-key", API_KEY)
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
}
return response.body();
}
static String meta(String json, String name) { // a JSON library replaces this in an application
Matcher matcher = Pattern.compile("\"" + name + "\"\\s*:\\s*\"?([^\",}]*)\"?").matcher(json);
return matcher.find() ? matcher.group(1) : "";
}
public static void main(String[] args) throws Exception {
String query = "direction=received&order=asc&limit=50&from=2026-09-01T00:00:00Z";
String cursor = "";
do {
String page = fetchPage(cursor.isEmpty() ? query : query + "&cursor=" + cursor);
Matcher ids = Pattern.compile("\"_id\"\\s*:\\s*\"([^\"]+)\"").matcher(page);
while (ids.find()) {
System.out.println(ids.group(1));
}
cursor = meta(page, "hasMore").equals("true") ? meta(page, "nextCursor") : ""; // store it to resume
} while (!cursor.isEmpty());
}
}Handle errors
A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Errors {
static final String BASE_URL = System.getenv().getOrDefault("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1");
static final String API_KEY = System.getenv("TEXTBEE_API_KEY");
static final HttpClient client = HttpClient.newHttpClient();
static String sendSms(String body, boolean retried) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/gateway/send-sms"))
.header("x-api-key", API_KEY)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status == 200) {
return response.body();
}
if (status == 429 && !retried) {
Thread.sleep(2000); // a plan limit or a burst; one retry after a pause
return sendSms(body, true);
}
if (status == 401) {
throw new IllegalStateException("API key rejected: " + response.body());
}
if (status == 400) {
throw new IllegalStateException("Request rejected: " + response.body());
}
throw new IllegalStateException("HTTP " + status + ": " + response.body());
}
public static void main(String[] args) throws Exception {
String body = "{\"recipients\": [\"+12015550123\"], \"message\": \"Hello from textbee\"}";
System.out.println(sendSms(body, false));
}
}Using it in Spring
Wrap the send call in a @Service that uses RestClient or WebClient with the key from application properties, and call it from a controller or an @Async method. The webhook is a @PostMapping that takes the body as byte[] or String, verifies the HMAC before deserialising, and returns ResponseEntity.ok().
Honest limits
One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.
Frequently asked questions
Is there a Java SDK for textbee?
No official one. java.net.http covers the calls, and any JSON library handles the responses.
Why do the samples parse JSON with regular expressions?
So they run with no dependencies. In an application use Jackson or Gson; the response shapes are documented in the API reference.
Can I send from an Android app written in Java?
You can call the API from any JVM, but the sending phone is a separate device running the textbee app. An Android app that calls the API is a client, not the gateway.
How do I test without sending a real message?
Set TEXTBEE_BASE_URL to a local stub that returns the documented response shape. The published samples are executed against one.
Read next
- Send SMS from Java: No Twilio, Just Your Android Phone
- How to Receive SMS and Process Webhooks with textbee
- API reference
- OTP and verification guides
- SMS gateway for the United States
- Webhook, defined
- E.164 phone number format, defined
- Send and receive SMS in Go with a REST API
- Send and receive SMS in Ruby with a REST API
- All languages