Новое Safe Mode прогревает новые номера и снижает риск блокировки в WhatsApp. Подробнее →

Maytapi/Blog/Tutorials

Build a WhatsApp Bot in Java: HttpClient, Webhooks and a List Menu

Build a WhatsApp bot with Java 17, HttpClient and the JDK HTTP server: filter webhooks, route commands with a list menu and reply with images and PDFs.

Tutorials Updated Oct 27, 2026·7 min read

Build a WhatsApp Bot in Java: HttpClient, Webhooks and a List Menu

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 id field returned by GET /listPhones.

The Maven dependency looks like this:

xml
<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:

Terminal
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:

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:

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", "7916xxxxx67", "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:

Terminal
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:

JSON
{
  "type": "message",
  "product_id": "your-product-id",
  "phone_id": "12345",
  "user": { "id": "7916xxxxx67@c.us", "name": "Customer", "phone": "7916xxxxx67" },
  "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "menu", "fromMe": false },
  "conversation": "7916xxxxx67@c.us",
  "receiver": "7916xxxxx21",
  "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.

Workflow of the Java webhook: read the request body, return 200 at once, skip non-message events, fromMe and group chats, then hand the event to a worker thread that posts the answer to the reply URL.

com.sun.net.httpserver.HttpServer is part of the JDK, so the webhook needs no extra dependency. Create Bot.java:

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:

java
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:

java
    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:

java
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:

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.

Production checklist for a Java WhatsApp bot: return 200 before work, skip repeated message ids, use a bounded worker pool, alert on status events, keep the token in the environment and only message people who wrote first.

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:

java
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 TOKEN to 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:

java
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:

Published Apr 14, 2026 · Updated Oct 27, 2026 ← All posts
Начните работать с WhatsApp API

Получите ключ и отправьте первое сообщение за десять минут

Без одобрения, без шаблонов, без карты. Все эндпоинты открыты на три дня.