WhatsApp API & Developers

WhatsApp API in Java: HttpClient and Spring Boot Guide

Java teams usually meet WhatsApp through a requirement that lands on an existing service: the policy system needs renewal reminders, the logistics backend needs delivery updates, the CRM needs to log replies. Pulling in a third-party SDK for a handful of HTTP calls is hard to justify in a codebase that already has strict dependency rules.

This WhatsApp API Java guide uses the JDK's own java.net.http.HttpClient for sending and a plain Spring Boot controller for webhooks. You will send text and template messages, verify Meta's HMAC signature on the raw bytes, hand work off the request thread, and send in bulk without tripping the throughput limit.

The last section shows the same pieces against Tenashi's API, for messages that should come from your team's existing number.

Versions and setup

HttpClient has been in the JDK since Java 11. The code below uses a few newer conveniences (HexFormat, Stream.toList(), records), so it targets Java 17, which is also the minimum for Spring Boot 3. The bulk-send example uses virtual threads and needs Java 21. JSON goes through Jackson, which Spring Boot already ships.

Spring Boot 4 moved to Jackson 3, whose packages start with tools.jackson instead of com.fasterxml.jackson. The imports below are Jackson 2 as used by Spring Boot 3; on Boot 4, adjust them.

You need a system user access token, the phone number ID of your sending number, the app secret and a verify token you choose. Put them in application.properties and keep the real values in environment variables:

whatsapp.token=${WHATSAPP_TOKEN}
whatsapp.phone-number-id=${PHONE_NUMBER_ID}
whatsapp.verify-token=${VERIFY_TOKEN}
whatsapp.app-secret=${APP_SECRET}
tenashi.webhook-secret=${TENASHI_WEBHOOK_SECRET}

The client uses Graph API v23.0; use the current version from Meta's changelog when you build.

Send messages with java.net.http.HttpClient

Create one HttpClient and share it. It is thread-safe, keeps connections alive and negotiates HTTP/2, so building one per request throws away the connection pool.

package com.example.wa;

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.HashMap;
import java.util.List;
import java.util.Map;

public class WhatsAppClient {
    private static final String GRAPH = "https://graph.facebook.com/v23.0/";

    // One HttpClient for the whole app: it is thread-safe and pools connections.
    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    private final ObjectMapper json = new ObjectMapper();
    private final String token;
    private final String phoneNumberId;

    public WhatsAppClient(String token, String phoneNumberId) {
        this.token = token;
        this.phoneNumberId = phoneNumberId;
    }

    /** to is a phone number with country code, e.g. +919811122233. Returns the wamid. */
    public String sendText(String to, String body) throws Exception {
        return send(Map.of(
                "to", to,
                "type", "text",
                "text", Map.of("body", body)));
    }

    public String sendTemplate(String to, String name, String language, List<String> params)
            throws Exception {
        List<Map<String, String>> parameters = params.stream()
                .map(p -> Map.of("type", "text", "text", p))
                .toList();
        return send(Map.of(
                "to", to,
                "type", "template",
                "template", Map.of(
                        "name", name,
                        "language", Map.of("code", language),
                        "components", List.of(Map.of("type", "body", "parameters", parameters)))));
    }

    private String send(Map<String, Object> message) throws Exception {
        Map<String, Object> payload = new HashMap<>(message);
        payload.put("messaging_product", "whatsapp");
        payload.put("recipient_type", "individual");

        HttpRequest request = HttpRequest.newBuilder(URI.create(GRAPH + phoneNumberId + "/messages"))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + token)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload)))
                .build();

        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        JsonNode body = json.readTree(response.body());
        if (response.statusCode() >= 400) {
            JsonNode error = body.path("error");
            throw new WhatsAppException(error.path("code").asInt(), error.path("message").asText());
        }
        return body.path("messages").path(0).path("id").asText();
    }

    public static class WhatsAppException extends Exception {
        public final int code;

        public WhatsAppException(int code, String message) {
            super(code + ": " + message);
            this.code = code;
        }
    }
}

Building the payload from Map and letting Jackson serialise it avoids the classic Java bug of formatting JSON with String.format, where a customer name containing a quote produces invalid JSON. There are two timeouts on purpose: connectTimeout on the client and a per-request timeout, without which a stalled response can hold a thread indefinitely.

The exception keeps Meta's numeric code, which is what you branch on. 190 is an expired token, 132001 a template that does not exist in that language, 130429 a throughput limit. Template parameters here are positional and fill the template's numbered placeholders in order; if your templates use named parameters, each entry also needs a parameter_name. WhatsApp template variables explains both formats.

A 200 from this call only means Meta accepted the request. Delivery arrives later as a status webhook, keyed by the wamid this method returns.

Receive and verify webhooks in Spring Boot

Meta verifies your callback URL with a GET, then delivers messages and statuses as signed POSTs. The signature is sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your app secret.

package com.example.wa;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class Signatures {
    private Signatures() {}

    /** Meta: X-Hub-Signature-256 is "sha256=" + hex HMAC-SHA256 of the raw body with the app secret. */
    public static boolean metaValid(byte[] rawBody, String header, String appSecret) {
        String expected = "sha256=" + hmacHex(appSecret, rawBody);
        return header != null && constantTimeEquals(expected, header);
    }

    /** Tenashi: X-Tenashi-Signature is "v1=" + hex HMAC-SHA256 of timestamp + "." + raw body. */
    public static boolean tenashiValid(byte[] rawBody, String timestamp, String header, String secret) {
        if (timestamp == null || header == null) return false;
        long age;
        try {
            age = Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp));
        } catch (NumberFormatException e) {
            return false;
        }
        byte[] prefix = (timestamp + ".").getBytes(StandardCharsets.UTF_8);
        byte[] signed = new byte[prefix.length + rawBody.length];
        System.arraycopy(prefix, 0, signed, 0, prefix.length);
        System.arraycopy(rawBody, 0, signed, prefix.length, rawBody.length);
        String expected = hmacHex(secret, signed);
        return age < 300 && constantTimeEquals(expected, header.replaceFirst("^v1=", ""));
    }

    private static String hmacHex(String secret, byte[] data) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(data));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    private static boolean constantTimeEquals(String a, String b) {
        return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
    }
}

MessageDigest.isEqual is the JDK's constant-time comparison. String.equals returns at the first differing character, which leaks timing information to anyone probing your endpoint.

Now the controller:

package com.example.wa;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/webhook")
public class WebhookController {
    private final ObjectMapper json = new ObjectMapper();
    private final ExecutorService worker = Executors.newFixedThreadPool(4);
    private final WhatsAppClient client;
    private final String verifyToken;
    private final String appSecret;

    public WebhookController(
            @Value("${whatsapp.token}") String token,
            @Value("${whatsapp.phone-number-id}") String phoneNumberId,
            @Value("${whatsapp.verify-token}") String verifyToken,
            @Value("${whatsapp.app-secret}") String appSecret) {
        this.client = new WhatsAppClient(token, phoneNumberId);
        this.verifyToken = verifyToken;
        this.appSecret = appSecret;
    }

    @GetMapping
    public ResponseEntity<String> verify(
            @RequestParam("hub.mode") String mode,
            @RequestParam("hub.verify_token") String token,
            @RequestParam("hub.challenge") String challenge) {
        if ("subscribe".equals(mode) && verifyToken.equals(token)) {
            return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(challenge);
        }
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }

    // byte[] makes Spring hand over the raw body untouched, which the HMAC needs.
    @PostMapping
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature,
            @RequestBody byte[] body) {
        if (!Signatures.metaValid(body, signature, appSecret)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        worker.submit(() -> handle(body)); // reply 200 now, do the work off the request thread
        return ResponseEntity.ok().build();
    }

    private void handle(byte[] body) {
        try {
            JsonNode root = json.readTree(body);
            for (JsonNode entry : root.path("entry")) {
                for (JsonNode change : entry.path("changes")) {
                    JsonNode value = change.path("value");
                    for (JsonNode msg : value.path("messages")) {
                        // "from" can be absent for users with a username: store from_user_id too.
                        String from = msg.path("from").asText(null);
                        if (from != null && "text".equals(msg.path("type").asText())) {
                            client.sendText(from, "Thanks, we got your message and will reply here.");
                        }
                    }
                    for (JsonNode status : value.path("statuses")) {
                        System.out.println(status.path("id").asText() + " " + status.path("status").asText());
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace(); // log and alert in real code
        }
    }
}

The Spring details that matter:

  • @RequestBody byte[] binds the body through Spring's byte-array converter, so you get the exact bytes Meta signed. Binding to a DTO or a Map parses first and the signature can no longer be checked reliably.
  • Dotted parameter names work as-is in @RequestParam("hub.mode"); no renaming needed, unlike PHP.
  • The challenge goes back as text/plain. Returning it inside a JSON object fails verification.
  • The executor. Meta retries any delivery that does not get a 200, for up to 7 days, and duplicates are possible. Acknowledge immediately and deduplicate on the message id in handle. In production, publish to your existing queue (Kafka, SQS, RabbitMQ) instead of a local pool.

If Spring Security is on the classpath, both webhook paths will get 401 or 403 before your controller runs, because Meta sends no session and no CSRF token. Merge something like this into your existing configuration:

package com.example.wa;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain security(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.ignoringRequestMatchers("/webhook", "/tenashi"))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/webhook", "/tenashi").permitAll()
                .anyRequest().authenticated());
        return http.build();
    }
}

The HMAC check is the authentication for these routes, so permitAll here is correct, not a hole. For the full list of webhook fields and how to subscribe the app, see the WhatsApp API webhooks guide.

Bulk sends without hitting the throughput limit

Java services are often the ones asked to send a few thousand renewal reminders overnight. Two separate limits apply, and they are easy to confuse.

Throughput is speed: 80 messages per second per number by default, covering inbound and outbound, with error 130429 when you exceed it. Messaging limits are reach: how many unique users you can message outside a customer service window in a moving 24 hours, starting at 250 for new portfolios. A perfectly paced job still fails after the 250th new recipient if your portfolio has not scaled. Read WhatsApp API throughput and the messaging limits guide before scheduling a large run.

On Java 21, virtual threads make the pacing code simple:

package com.example.wa;

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Bulk {
    record Customer(String phone, String firstName, String renewalDate) {}

    static void sendRenewals(WhatsAppClient client, List<Customer> customers) throws InterruptedException {
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { // Java 21
            for (Customer c : customers) {
                pool.submit(() -> {
                    try {
                        String wamid = client.sendTemplate(c.phone(), "renewal_reminder", "en_US",
                                List.of(c.firstName(), c.renewalDate()));
                        System.out.println(c.phone() + " accepted " + wamid);
                    } catch (WhatsAppClient.WhatsAppException e) {
                        // 130429: throughput exceeded, retry later; 131056: too many to one user
                        System.err.println(c.phone() + " failed " + e.code);
                    }
                    return null;
                });
                Thread.sleep(25); // roughly 40 requests a second, half the default 80 mps
            }
        } // close() waits for every in-flight send to finish
    }
}

Each send blocks its own cheap virtual thread, the 25 ms gap keeps you near 40 requests per second, and the try-with-resources block waits for stragglers. Persist each wamid against the customer so status webhooks can mark the row delivered or failed.

The Tenashi version: same HttpClient, your own number

When reminders and replies should come from the WhatsApp number your team already uses, Tenashi's API is one endpoint with a bearer key, and no templates.

package com.example.wa;

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 class TenashiClient {
    private static final URI MESSAGES = URI.create("https://usetenashi.com/api/v1/messages");
    private final HttpClient http = HttpClient.newHttpClient();
    private final ObjectMapper json = new ObjectMapper();
    private final String apiKey;

    public TenashiClient(String apiKey) {
        this.apiKey = apiKey;
    }

    /** message: {"to": "+91..."} or {"conversationId": "..."} plus "text". Returns the queued message. */
    public JsonNode send(Map<String, Object> message, String idempotencyKey) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(MESSAGES)
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", idempotencyKey)
                .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(message)))
                .build();
        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        JsonNode body = json.readTree(response.body());
        if (response.statusCode() != 202) {
            JsonNode error = body.path("error");
            throw new IllegalStateException(
                    response.statusCode() + " " + error.path("code").asText() + ": " + error.path("message").asText());
        }
        return body.path("message"); // status "queued"; webhooks report the rest
    }
}

Call it with tenashi.send(Map.of("to", "+919811122233", "text", "Hi Priya, your renewal is due on 5 November."), "renewal-8812"). The idempotency key makes a retry after a timeout safe: a repeat within 24 hours returns the first response and sends nothing.

Do not port the bulk loop above to Tenashi. Sends are queued and paced per number at one every 4 to 12 seconds, a batch of 100 takes about 10 minutes, and the API allows 50 new chats per number per day. Submit and let the queue do the pacing. Personalise each text too: the same exact text to 5 chats within 10 minutes is paused with 422 send_blocked.

Webhooks use the tenashiValid method from Signatures:

package com.example.wa;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
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.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TenashiWebhookController {
    private final ObjectMapper json = new ObjectMapper();
    private final Set<String> seen = ConcurrentHashMap.newKeySet(); // use a DB table in production
    private final String secret;

    public TenashiWebhookController(@Value("${tenashi.webhook-secret}") String secret) {
        this.secret = secret;
    }

    @PostMapping("/tenashi")
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "X-Tenashi-Timestamp", required = false) String timestamp,
            @RequestHeader(value = "X-Tenashi-Signature", required = false) String signature,
            @RequestBody byte[] body) throws Exception {
        if (!Signatures.tenashiValid(body, timestamp, signature, secret)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        JsonNode event = json.readTree(body);
        if (seen.add(event.path("id").asText())) { // false means we already handled it
            JsonNode msg = event.path("data").path("message");
            System.out.println(event.path("type").asText() + " in " + msg.path("chat").path("type").asText());
        }
        return ResponseEntity.ok().build();
    }
}

Reply with a 2xx within 10 seconds, and expect events at least once and not always in order.

Where Tenashi fits

The Cloud API code is the right choice when your Java service sends OTPs, needs a green tick or pushes thousands of alerts an hour. Many enterprise teams have a different problem: account managers already talk to customers on WhatsApp from their own numbers, and the backend needs to post reminders into those same threads and log the replies.

Tenashi links existing WhatsApp or WhatsApp Business numbers by QR scan. Your service sends through the API, every message lands in a shared inbox with a CRM, tickets and SLAs under each chat, and signed webhooks report replies from DMs and groups. It is not Meta's platform: no templates, interactive messages or Official Business Account, and it is paced for conversations, not bulk. WhatsApp can still ban a number that spams. From $18/mo, with the API on every paid plan.

Read the API docs · Get started

FAQ

Which Java version do I need for the WhatsApp API?

java.net.http.HttpClient needs Java 11. The examples here use Java 17 features such as HexFormat and records, and Spring Boot 3 requires 17 anyway. The bulk example uses virtual threads, which need Java 21. Nothing in the WhatsApp API itself depends on the Java version; it is plain HTTPS and JSON.

Why does my Spring Boot webhook return 403 to Meta?

Most often Spring Security is rejecting the POST because there is no CSRF token or session. Exclude the webhook path from CSRF and permit it, since the HMAC signature is the real authentication. If it is the GET that fails, check that the verify token matches and that the challenge is returned as plain text.

Should I use sendAsync instead of send?

Either is fine. sendAsync returns a CompletableFuture and avoids blocking a platform thread, which helps on Java 17. On Java 21, blocking send inside virtual threads gives the same scalability with simpler code. What matters more is pacing requests under the throughput limit and handling error codes per message.

How do I test the webhook locally?

Run the Spring Boot app on port 8080 and expose it with an HTTPS tunnel such as ngrok, because Meta needs a valid certificate. Register the tunnel URL plus /webhook with your verify token in the App Dashboard and subscribe to the messages field. Then message your test number from your phone and watch the logs.

Where should I store message IDs and statuses?

In a table keyed by the wamid, with the customer, template, current status, timestamp and any error code. Update it from status webhooks, and only move forward: if a late sent arrives after read, keep read. That table also answers the support question every team gets: did the customer actually receive it?


That covers sending, receiving and verifying from Java with nothing beyond the JDK and Spring. For templates, pricing and limits, see the complete guide to the WhatsApp API, and to send from your team's own number, read the Tenashi API docs.