Building with OpenWA Java Whatsapp Bots

I Built a WhatsApp Bot in Java and It Was Weirder Than I Expected

A few months back, I was working on a customer notification service for a mid-sized e-commerce client. They had email covered, SMS was wired up through Twilio, push notifications were going out fine. But their customers kept asking the same question during support calls: "Can you just send me a WhatsApp message?" Sounds simple. It's not. The official route, the WhatsApp Business API through Meta, requires business verification, a phone number review process, message template approvals, and depending on your volume tier, a monthly cost that made the client's finance team visibly uncomfortable. We needed something faster to prototype with, something I could wire into our Spring Boot service without a two-week onboarding process. That's when I started looking at OpenWA . What OpenWA Actually Is OpenWA (sometimes written as open-wa, originally called wa-automate) is an open-source Node.js library that wraps the WhatsApp Web client. It runs a headless Chromium session logged into your WhatsApp Web account and exposes an API you can call from basically anything: REST, WebSocket, whatever you want to talk to it in. The key thing to understand is that this is not the official API. It's automation of the web client. That distinction matters a lot depending on what you're building and who's using it. From a Java developer's perspective, OpenWA exposes an HTTP REST server (when you configure it that way), which means your Spring Boot app doesn't care at all that there's a Node.js process running somewhere. You just hit endpoints. That's the whole integration point, really. I'll be honest: when our backend lead first suggested this during a Thursday afternoon planning session, I was skeptical. "We're going to run a headless browser to send WhatsApp messages?" But it worked. And for internal tooling and prototyping, it worked surprisingly well. Setting Up the OpenWA Server Before writing any Java, you need OpenWA running. You install it via npm and create a small launcher script. Here's the minimal version: npm install @open-wa/wa-automate Then a start.js file: const { create, ev } = require('@open-wa/wa-automate'); create({ sessionId: 'my-session', useChrome: true, headless: true, restMode: true, port: 8080, apiRequestCheck: (req) => { return req.headers['x-api-key'] === process.env.API_KEY; } }).then(client => { ev.emit('qr.**', (qrcode) => { console.log(qrcode); }); }); Run node start.js , scan the QR code that appears in the terminal (it renders as ASCII art, which is not something I was expecting), and you're authenticated. OpenWA saves the session so you don't re-scan every restart. Once it's up, the REST server is listening on port 8080. First startup takes a while because it's spinning up Chromium. On the small EC2 t3.small we used for testing, the first launch was maybe 45 seconds. Subsequent starts with a saved session were closer to 10. Not fast, but fine. The Java Side: Calling the OpenWA REST API OpenWA's REST mode exposes documented endpoints. Sending a message is a POST to /sendText . That's it. No SDK needed. I used RestTemplate on that project because we were still on Spring Boot 2.7, though honestly if I were starting fresh today I'd use WebClient or just the java.net.http.HttpClient that's been solid since Java 11. Here's the service class I put together: @Service public class WhatsAppService { private final RestTemplate restTemplate; private final String openWaBaseUrl; private final String apiKey; public WhatsAppService( RestTemplate restTemplate, @Value("${openwa.base-url}") String openWaBaseUrl, @Value("${openwa.api-key}") String apiKey ) { this.restTemplate = restTemplate; this.openWaBaseUrl = openWaBaseUrl; this.apiKey = apiKey; } public boolean sendMessage(String phoneNumber, String message) { String url = openWaBaseUrl + "/sendText"; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("x-api-key", apiKey); Map<String, String> body = Map.of( "to", phoneNumber + "@c.us", "content", message ); HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers); try { ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class); return response.getStatusCode().is2xxSuccessful(); } catch (RestClientException e) { log.error("Failed to send WhatsApp message to {}: {}", phoneNumber, e.getMessage()); return false; } } } The @c.us suffix on the phone number is an OpenWA thing. Phone numbers need to be in international format without the + , so a US number like +1 555 123 4567 becomes 15551234567@c.us . I forgot this the first time and spent longer than I'd like to admit staring at silent failures with no error message. Fun stuff. Sending Different Message Types Plain text is the easy part. OpenWA also handles images, files, and even buttons, though button support depends on your WhatsApp version and is a bit flaky in my experience. I wouldn't build anything critical on top of the button stuff. Sending an image by URL: public boolean sendImage(String phoneNumber, String imageUrl, String caption) { String url = openWaBaseUrl + "/sendImage"; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("x-api-key", apiKey); Map<String, String> body = Map.of( "to", phoneNumber + "@c.us", "url", imageUrl, "caption", caption, "filename", "image.jpg" ); HttpEntity<Map<String, String>> request = new HttpEntity<>(body, headers); try { ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class); return response.getStatusCode().is2xxSuccessful(); } catch (RestClientException e) { log.error("Failed to send WhatsApp image to {}: {}", phoneNumber, e.getMessage()); return false; } } For PDFs and documents, the endpoint is /sendFile . Same structure, just swap the endpoint and make sure the filename field has the right extension, because that's what WhatsApp uses to decide how to display it. Get that wrong and your PDF shows up as a generic file with no preview. Ask me how I know. Receiving Messages (Webhooks) This is where things get more interesting. OpenWA can POST to a webhook URL whenever messages come in. You configure it in your create() call: create({ sessionId: 'my-session', useChrome: true, headless: true, restMode: true, port: 8080, webhook: 'http://your-java-service:8090/webhooks/whatsapp' }) Then on the Java side, you listen for it: @RestController @RequestMapping("/webhooks") public class WhatsAppWebhookController { private final MessageProcessingService messageProcessingService; public WhatsAppWebhookController(MessageProcessingService messageProcessingService) { this.messageProcessingService = messageProcessingService; } @PostMapping("/whatsapp") public ResponseEntity<Void> handleIncoming(@RequestBody Map<String, Object> payload) { String eventType = (String) payload.get("type"); if ("message".equals(eventType)) { Map<String, Object> messageData = (Map<String, Object>) payload.get("data"); String from = (String) messageData.get("from"); String body = (String) messageData.get("body"); messageProcessingService.handle(from, body); } return ResponseEntity.ok().build(); } } The payload structure takes some reading to understand. There are different event types ( message , message_ack , group_join , etc.) and you'll want to filter carefully. I'd recommend logging the raw payload for a while before building any business logic on top of it. The first time I deployed this, I was genuinely surpri...