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.
| Operation | Method and path | Required fields |
|---|---|---|
| Send text | POST /api/v1/messages/send/text | sessionId, to, text |
| Send image | POST /api/v1/messages/send/image | sessionId, to, image (caption optional) |
| Get session | GET /api/v1/sessions/{sessionId} | sessionId in the path |
| Error response | any route | success, 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.
