
Java has shipped a usable HTTP client since Java 11 and a small HTTP server inside the JDK for much longer, so a WhatsApp bot doesn't need a framework. This tutorial builds a WhatsApp bot in Java with the Maytapi API, using Java 17, java.net.http.HttpClient for outgoing requests, the JDK's com.sun.net.httpserver for the webhook and Jackson for JSON. You'll send a first message, receive webhook events, route commands through a list menu and reply with images and documents. A Spring Boot version of the webhook comes at the end.
Maytapi is an unofficial WhatsApp API. You link your own WhatsApp number as a device by scanning a QR code, the same way WhatsApp Web works, and then control it over REST and webhooks. There are no message templates and no per-message fees. The other side of that is that your bot speaks from a normal WhatsApp account, so WhatsApp's anti-spam rules apply to it. The production section links to the details.
What you need
- Java 17 or newer. The code uses switch expressions and
var, and nothing newer than that. - Maven or Gradle with one dependency,
com.fasterxml.jackson.core:jackson-databind. Any recent 2.x version works. - A Maytapi account with a connected phone. The free trial runs for three days and doesn't ask for a card. Scan the QR code in the console with the number the bot should use.
- Your product ID, token and phone ID. The product ID and token are on the console's Product ID & Token page. The phone ID is on the Phones page, or in the
idfield returned byGET /listPhones.
The Maven dependency looks like this:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Pass the credentials as environment variables, so the same build runs on your laptop and on the server:
export PRODUCT_ID="your-product-id"
export PHONE_ID="your-phone-id"
export TOKEN="your-token"
Send a WhatsApp message with HttpClient
Every request goes to https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/ with the token in the x-maytapi-key header. One shared HttpClient is enough for the whole bot, because it's thread-safe and reuses connections. Put it in Maytapi.java:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public final class Maytapi {
static final ObjectMapper JSON = new ObjectMapper();
private static final String TOKEN = System.getenv("TOKEN");
private static final String PREFIX = "https://api.maytapi.com/api/" + System.getenv("PRODUCT_ID") + "/";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private Maytapi() {}
static JsonNode post(String url, Map<String, Object> body) {
// webhooks are unauthenticated: only send the token to your own Maytapi product
if (url == null || !url.startsWith(PREFIX)) {
return JSON.createObjectNode().put("success", false).put("message", "unexpected URL");
}
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.header("x-maytapi-key", TOKEN)
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return JSON.readTree(res.body());
} catch (Exception e) {
return JSON.createObjectNode().put("success", false).put("message", e.toString());
}
}
}
Now check that the number can send. Create SendTest.java:
import java.util.Map;
public class SendTest {
public static void main(String[] args) {
String PRODUCT_ID = System.getenv("PRODUCT_ID");
String PHONE_ID = System.getenv("PHONE_ID");
var res = Maytapi.post(
"https://api.maytapi.com/api/" + PRODUCT_ID + "/" + PHONE_ID + "/sendMessage",
Map.of("to_number", "1415xxxxx67", "type", "text", "message", "Hello from Java!"));
System.out.println(res.toPrettyString());
}
}
Use your own number in to_number: country code first, no + and no spaces. A successful call returns success: true and a data object with the chatId and msgId. POST /sendMessage puts the message in the phone's sending queue, so if nothing arrives, check the phone's status in the console first. The Java quick reference has more one-off request examples.
Register your webhook URL
Incoming messages, delivery receipts and phone status changes all go to one URL, set at product level with POST /setWebhook:
curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \
-H "x-maytapi-key: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"webhook": "https://bot.example.com/maytapi/webhook"}'
Maytapi checks the URL by posting a sample message event before it saves it, so start the bot first and register second. The sample has no reply field, and the handler below skips events without one. If one product runs several numbers and each should reach a different server, POST /config sets a webhook for a single phone. The webhook events guide covers every event type.
Receive messages with the JDK HTTP server
Each event arrives as a JSON POST. An incoming text message looks like this:
{
"type": "message",
"product_id": "your-product-id",
"phone_id": "12345",
"user": { "id": "1415xxxxx67@c.us", "name": "Customer", "phone": "1415xxxxx67" },
"message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "menu", "fromMe": false },
"conversation": "1415xxxxx67@c.us",
"receiver": "1415xxxxx21",
"timestamp": 1574081887,
"reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage"
}
conversation is the chat to answer in. reply is the sendMessage URL of the phone that received the message, so the bot never has to build URLs, and a bot serving several numbers always answers from the right one.
Check the reply URL before you send your token. Anyone who learns your webhook address can post a fake event to it, so never send x-maytapi-key to a URL taken from the request without checking it. The send helper only accepts a reply that starts with https://api.maytapi.com/api/ followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example /maytapi/webhook?key=…) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the webhook events guide lets you accept deliveries from one address only.
com.sun.net.httpserver.HttpServer is part of the JDK, so the webhook needs no extra dependency. Create Bot.java:
import com.fasterxml.jackson.databind.JsonNode;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Bot {
private static final ExecutorService WORKERS = Executors.newFixedThreadPool(4);
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/maytapi/webhook", Bot::webhook);
server.start();
System.out.println("Listening on http://localhost:8080/maytapi/webhook");
}
private static void webhook(HttpExchange ex) throws IOException {
byte[] raw = ex.getRequestBody().readAllBytes();
ex.sendResponseHeaders(200, -1); // answer Maytapi first, no body
ex.close();
JsonNode event = parse(raw);
if (event == null || !"message".equals(event.path("type").asText())) return;
JsonNode msg = event.path("message");
String chat = event.path("conversation").asText("");
if (msg.path("fromMe").asBoolean(false)) return; // our own messages, including bot replies
if (chat.endsWith("@g.us")) return; // one-to-one chats only
if (event.path("reply").asText("").isEmpty()) return; // e.g. the setWebhook check
WORKERS.submit(() -> Router.handle(event));
}
private static JsonNode parse(byte[] raw) {
try {
return Maytapi.JSON.readTree(raw);
} catch (IOException e) {
return null;
}
}
}
The handler answers 200 before it does anything else, then passes the event to a small thread pool. Maytapi gets its response right away, and a slow reply never holds the connection open. fromMe is true for anything your own number sends, including the bot's replies, so skipping it stops the bot from answering itself in a loop. Group chat IDs end in @g.us, and a command bot that replies to every message in a busy group annoys people quickly. Remove that line once you've decided how the bot should behave in groups.
Route commands with a list menu
Customers don't remember typed commands, so give them a menu. A list message shows one button that opens titled sections, and each row has a rowId. When someone picks a row, the webhook receives a normal text message with the row title in message.text and the rowId in message.payload.
Create Router.java. Using the digits 1, 2 and 3 as row IDs means a tap and a typed "2" end up in the same case:
import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
final class Router {
// reactions, poll votes, edits, pins, calls and group notices need no answer
private static final Set<String> SILENT_TYPES =
Set.of("reaction", "pollVoteUpdate", "message_edit", "pinMessage", "info");
private Router() {}
static void handle(JsonNode event) {
JsonNode msg = event.path("message");
Map<String, Object> body;
if (SILENT_TYPES.contains(msg.path("type").asText())) {
return;
}
if (!"text".equals(msg.path("type").asText())) {
body = Map.of("type", "text", "message", "I can read text for now. Type *menu* to start.");
} else {
JsonNode input = msg.hasNonNull("payload") ? msg.get("payload") : msg.path("text");
String key = input.asText().trim().toLowerCase(Locale.ROOT);
body = switch (key) {
case "1", "prices" -> priceList();
case "2", "hours" -> openingHours();
case "3", "agent" -> Map.of("type", "text",
"message", "Thanks! A team member will reply in this chat.");
default -> menu();
};
}
Map<String, Object> out = new HashMap<>(body);
out.put("to_number", event.path("conversation").asText());
JsonNode res = Maytapi.post(event.path("reply").asText(), out);
if (!res.path("success").asBoolean(false)) {
System.err.println("sendMessage failed: " + res);
}
}
static Map<String, Object> menu() {
return Map.of(
"type", "list",
"title", "Acme Store",
"message", "Hi! How can we help? Tap the menu, or reply 1, 2 or 3.",
"footer", "Type menu anytime",
"button_text", "Open menu",
"sections", List.of(Map.of(
"title", "Shop",
"rows", List.of(
row("1", "Price list", "PDF with current prices"),
row("2", "Opening hours", "Store photo and hours"),
row("3", "Talk to a person", "A team member replies here")))),
"skip_filter", true);
}
private static Map<String, Object> row(String id, String title, String description) {
return Map.of("rowId", id, "title", title, "description", description);
}
}
Reactions, poll votes, edits, pins and info notices (calls, group changes) also arrive as message events, so the router skips them instead of answering. Anything the router doesn't recognize, including "hi", gets the menu, so a first-time customer always has a next step. Reading payload first and falling back to text keeps the flow working whether people tap or type.
The menu sets skip_filter. sendMessage has a duplicate filter that drops identical content sent to the same chat several times in a row. It protects you from accidental loops, but a menu is meant to repeat, so it opts out. Leave the filter on for everything else.
Lists are also the safest way to collect a choice. Reply-button taps don't reach the webhook as a regular message on every connection, so this bot uses list rows and accepts typed numbers too. The buttons, lists and carousels guide compares the formats.
Send images and documents
Images, videos, audio and documents all use type: "media". Put a public URL in message, an optional caption in text, and use filename to set the name a document shows in the chat. Add the two missing methods to Router:
static Map<String, Object> priceList() {
return Map.of(
"type", "media",
"message", "https://example.com/files/price-list.pdf",
"text", "Our current price list",
"filename", "acme-price-list.pdf");
}
static Map<String, Object> openingHours() {
return Map.of(
"type", "media",
"message", "https://example.com/images/store-front.jpg",
"text", "Open Mon-Sat, 09:00-19:00.\nReply menu to go back.");
}
If the file isn't public, send it inline as a base64 data URI instead of a URL:
byte[] pdf = Files.readAllBytes(Path.of("/srv/files/invoice-10482.pdf"));
String data = "data:application/pdf;base64," + Base64.getEncoder().encodeToString(pdf);
Map<String, Object> body = Map.of("type", "media", "message", data, "filename", "invoice-10482.pdf");
That snippet needs java.nio.file.Files, java.nio.file.Path and java.util.Base64. Customers can send files too. Incoming media arrives with a message.type such as image or document, plus url, mime, filename and an optional caption. To keep a copy, download url with the same HttpClient and BodyHandlers.ofFile(...). The router answers media with a short note for now, which is the place to hook that in.
Test the Java bot locally
Compile and start the bot, then expose port 8080 with a tunnel in a second terminal:
mvn -q compile exec:java -Dexec.mainClass=Bot
# second terminal, pick one:
cloudflared tunnel --url http://localhost:8080
ngrok http 8080
Register the HTTPS URL the tunnel prints, with /maytapi/webhook added, using setWebhook. Then message the bot's number from another phone: "hi" should bring up the menu, and "1" should send the PDF. Messages typed on the bot's own phone arrive with fromMe: true and get ignored. Free tunnel URLs usually change on restart, so if the bot goes quiet, check the webhook URL first.
Production checklist for a Java WhatsApp bot
Package the bot as a jar, run it behind a reverse proxy with HTTPS, and register the server's stable URL. Then go through these points before real customers arrive.
Answer first, work after. The thread pool already does this for quick replies. For AI calls or order lookups, keep the pool bounded, or put events on a real queue so a traffic spike can't exhaust memory.
Skip repeats. Handle each message.id once, so a redeploy or a duplicate delivery never sends the same PDF twice. A Java process keeps memory between requests, so a bounded map is enough for a single instance:
private static final Map<String, Boolean> SEEN = Collections.synchronizedMap(
new LinkedHashMap<String, Boolean>(1024, 0.75f, false) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Boolean> eldest) {
return size() > 10_000;
}
});
static boolean firstTime(String id) {
return SEEN.putIfAbsent(id, Boolean.TRUE) == null;
}
// in webhook(): if (!firstTime(msg.path("id").asText())) return;
Use a database or Redis instead once you run more than one instance or need the IDs to survive restarts.
Watch the phone. status events tell you when the number disconnects or returns to the QR screen. Alert someone when that happens. ack events report whether replies were delivered and read.
Send like a person would. A bot that answers people who wrote first is the safest kind of automation on an unofficial API. Large proactive sends from a new number are what get accounts restricted. Read how Safe Mode prevents WhatsApp bans before you add broadcasts.
Note: Never commit
TOKENto your repository or bake it into the jar. Read it from the environment or your secret manager.
The same webhook in Spring Boot
If the bot lives in a Spring Boot app, the webhook is a @RestController. Spring's Jackson setup binds the body straight to a JsonNode, and the Router and Maytapi classes above work unchanged:
import com.fasterxml.jackson.databind.JsonNode;
import java.util.concurrent.CompletableFuture;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebhookController {
@PostMapping("/maytapi/webhook")
public ResponseEntity<Void> webhook(@RequestBody JsonNode event) {
JsonNode msg = event.path("message");
String chat = event.path("conversation").asText("");
if ("message".equals(event.path("type").asText())
&& !msg.path("fromMe").asBoolean(false)
&& !chat.endsWith("@g.us")
&& !event.path("reply").asText("").isEmpty()) {
CompletableFuture.runAsync(() -> Router.handle(event));
}
return ResponseEntity.ok().build();
}
}
CompletableFuture.runAsync uses the common pool. In a larger app, pass your own executor or use @Async, so bot work doesn't compete with the rest of the application.
Next steps
Your Java bot now receives messages, shows a list menu and sends images and documents. To go further:
- Learn every webhook payload, including acks, status and group events, in the webhook events guide.
- Look up the body for every message type in the send message guide.
- Build richer menus with buttons, lists and carousels.
- Compare with the same bot in PHP, Node.js or Python.
- Check request bodies in the API documentation.



