By language

WhatsApp API in Java: native HttpClient and Spring Boot

To integrate the WhatsApp API in Java you don't need an extra library: the Java 11 HttpClient or Spring Boot's RestClient handles sending, and a controller with PostMapping receives the webhook. This guide shows both paths with timeouts configured and handling of the API error body.

By D-API engineering team6 min read

The API contract in Java terms

From your code's point of view, the D-API WhatsApp API is an ordinary REST service. Each operation is a request to https://api.d-api.cloud, with a JSON body and the Authorization header holding the API key with no prefix. There is no Java SDK, and you won't miss it: model two or three records and pick an HTTP client.

OperationMethod and pathRequired fields
Send textPOST /api/v1/messages/send/textsessionId, to, text
Send imagePOST /api/v1/messages/send/imagesessionId, to, image (caption optional)
Get sessionGET /api/v1/sessions/{sessionId}sessionId in the path
Error responseany routesuccess, error, statusCode

Plain Java with java.net.http.HttpClient

In a service without Spring, a batch job or a desktop app, the JDK's native client does the job. Create a single HttpClient instance and reuse it; it keeps the connection pool. Note the two timeouts: the builder's limits the time to open the connection, and the request's limits the wait for the response.

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.Map;

public final class DApiClient {
    private static final String BASE = "https://api.d-api.cloud";
    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(3))
            .build();
    private final ObjectMapper json = new ObjectMapper();
    private final String apiKey;

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

    public void sendText(String sessionId, String to, String text) throws Exception {
        String body = json.writeValueAsString(Map.of("sessionId", sessionId, "to", to, "text", text));
        HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/api/v1/messages/send/text"))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", apiKey)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        HttpResponse<String> res;
        try {
            res = http.send(req, HttpResponse.BodyHandlers.ofString());
        } catch (HttpTimeoutException e) {
            throw new DApiException(408, "no response within the timeout");
        }
        if (res.statusCode() >= 400) {
            ApiError err = json.readValue(res.body(), ApiError.class);
            throw new DApiException(res.statusCode(), err.error());
        }
    }

    public record ApiError(boolean success, String error, int statusCode) {}

    public static final class DApiException extends RuntimeException {
        public final int status;
        public DApiException(int status, String message) { super(message); this.status = status; }
    }
}

Jackson is there only to serialize the JSON; if the project already uses Gson, swapping is straightforward. HttpTimeoutException is thrown when the request time runs out, and it is worth handling on its own: in that case the message may have been delivered even though the response never arrived.

Spring Boot with RestClient

On Spring Boot 3.2 or later, RestClient is the idiomatic way to call APIs synchronously. Declare a bean configured with a base URL, header and a request factory with timeouts, and inject it where you need it.

@Configuration
class DApiConfig {
    @Bean
    RestClient dapiRestClient(@Value("${dapi.api-key}") String apiKey) {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(3));
        factory.setReadTimeout(Duration.ofSeconds(10));
        return RestClient.builder()
                .baseUrl("https://api.d-api.cloud")
                .defaultHeader("Authorization", apiKey)
                .requestFactory(factory)
                .build();
    }
}

@Service
class WhatsAppService {
    private final RestClient dapi;
    WhatsAppService(RestClient dapiRestClient) { this.dapi = dapiRestClient; }

    record SendImage(String sessionId, String to, String image, String caption) {}

    void sendReceipt(String sessionId, String to, String imageUrl) {
        dapi.post()
            .uri("/api/v1/messages/send/image")
            .contentType(MediaType.APPLICATION_JSON)
            .body(new SendImage(sessionId, to, imageUrl, "Your receipt"))
            .retrieve()
            .onStatus(HttpStatusCode::isError, (req, res) -> {
                String body = new String(res.getBody().readAllBytes());
                throw new DApiException(res.getStatusCode().value(), body);
            })
            .toBodilessEntity();
    }
}

onStatus turns any 4xx or 5xx into your own exception, carrying the API body, which gives the reason in the error field. A network failure or timeout arrives as ResourceAccessException. Keeping the two apart helps you decide what deserves a retry.

Receive the webhook with @PostMapping

Every event arrives with the same envelope, so one record with a JsonNode in the data field handles every type. The controller validates the token in the path, hands the event to an async service and responds. Since the WhatsApp webhook is not signed, this secret token is the main barrier against forged calls.

record WebhookEvent(String event, String sessionId, String timestamp, String traceId, JsonNode data) {}

@RestController
class WhatsAppWebhookController {
    private final EventProcessor processor;
    private final String token;

    WhatsAppWebhookController(EventProcessor processor, @Value("${dapi.webhook-token}") String token) {
        this.processor = processor;
        this.token = token;
    }

    @PostMapping("/webhooks/whatsapp/{token}")
    ResponseEntity<Void> receive(@PathVariable("token") String received, @RequestBody WebhookEvent event) {
        if (!MessageDigest.isEqual(token.getBytes(), received.getBytes())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        if ("messages.received".equals(event.event()) && !event.data().path("fromMe").asBoolean()) {
            processor.process(event); // @Async method, runs on another executor
        }
        return ResponseEntity.ok().build();
    }
}

Remember to enable @EnableAsync and define an executor with a bounded queue. In applications with more than one instance, or when processing calls other slow systems, replace @Async with a persistent queue. The list of events and what each one carries is in WhatsApp webhooks.

Monitor the connection through the webhook itself

In enterprise systems, the most common incident is not a message with an error but a number that stopped working without anyone noticing. The connection.status event solves that: it arrives at the same endpoint, with data.status set to connected, disconnected or logged_out. In the processor, handle this event before messages:

  • disconnected: log it and keep an eye on it. Short drops happen, and D-API itself tries to restore the connection.
  • logged_out: the device left the session and someone needs to scan the QR code again. This is the case for raising an alert in your on-call channel or notifying the customer who owns the number.
  • connected: close the alert and release any sends that were held back, if your application held them.

Turning these events into a metric, with one counter per status in Micrometer, lets your Grafana dashboard show how many connections are healthy at any moment, without polling the API.

Official or unofficial, same code

Many Java teams work at companies that evaluate Meta's official API for compliance reasons. On D-API, the type is chosen when the session is created (unofficial or cloud_api), and the resulting sessionId works on the same send routes shown above. The difference is in the rules: to start a conversation on the official API you need a template approved by Meta. The full comparison is in official vs unofficial API, and the product in official WhatsApp API. If you integrate WhatsApp into back-office systems, which is often the case in Java, also read about the WhatsApp API for ERP.

Frequently asked questions

Is there a D-API Java SDK on Maven Central?
No. The API is REST and any HTTP client in the Java ecosystem works: the native HttpClient, RestClient, WebClient, OkHttp or Feign. The examples on this page use only what ships with the JDK and Spring Boot.
Does it work on Java 8?
java.net.http.HttpClient only exists from Java 11 on. On Java 8 the integration is still possible with HttpURLConnection, Apache HttpClient or OkHttp, using the same endpoint, header and JSON body.
RestClient or WebClient in Spring?
If the application is traditional Spring MVC, RestClient, available from Spring Framework 6.1, is simpler and synchronous. WebClient makes sense when the whole application is already reactive with WebFlux.
Does the code change if I use the official Meta API?
Sending text and media stays on the same route, with the sessionId of the official connection. What changes is starting a conversation: on the official API, outside the customer service window, you need an approved template, through the template route.
How do I process the webhook without holding a Tomcat thread?
Respond 200 right after validating and hand the event to an executor, an Async method or a queue. On Java 21, a virtual thread executor is a lightweight option for this work.

Try D-API's WhatsApp API

3-day trial with full access. No credit card, no lock-in.