DEV Community

dodou
dodou

Posted on

Call a SERP API from Java: HttpClient and JSON in Practice

Java 11 ships with java.net.http.HttpClient, so calling a SERP API from Java needs one small dependency for JSON and nothing else. Here's a client class that posts a query, checks the API-level status, maps results to a record, and paginates — about eighty lines, no SDK.

The request you're making

POST https://api.serpbase.dev/google/search with an X-API-Key header and a JSON body: q is required; hl (language, defaults to en), gl (country, defaults to us), page (1-based, defaults to 1) and device (default / pc / mobile, search endpoint only) are optional. A successful request costs 1 credit.

Add Jackson for JSON handling:

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.17.2</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

The client

package dev.serpbase.examples;

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

public class SerpClient {
    private static final String API_URL = "https://api.serpbase.dev/google/search";
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private final String apiKey;

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

    public record OrganicResult(int rank, String title, String link, String snippet) {}

    public List<OrganicResult> searchPage(String query, int page) throws Exception {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("q", query);
        payload.put("hl", "en");
        payload.put("gl", "us");
        payload.put("page", page);
        payload.put("device", "default");

        HttpRequest request = HttpRequest.newBuilder(URI.create(API_URL))
                .header("X-API-Key", apiKey)
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(30))
                .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
                .build();

        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
        }

        JsonNode root = MAPPER.readTree(response.body());
        int status = root.path("status").asInt(-1);
        if (status != 0) {
            throw new IllegalStateException("status=" + status
                    + " error=" + root.path("error").asText()
                    + " request_id=" + root.path("request_id").asText());
        }

        List<OrganicResult> results = new ArrayList<>();
        for (JsonNode item : root.path("organic")) {
            results.add(new OrganicResult(
                    item.path("rank").asInt(),
                    item.path("title").asText(),
                    item.path("link").asText(),
                    item.path("snippet").asText("")));
        }
        return results;
    }
}
Enter fullscreen mode Exit fullscreen mode

The endpoint shape and field names above come from the SerpBase search API reference, which documents rank as the 1-based position within the response, with position as an alias, plus optional fields like url, date and sitelinks. I only map the four fields I need and let Jackson ignore the rest.

Two things to notice in the code:

  • There are two error layers. A non-200 HTTP status is a transport problem; anything else arrives in the JSON body as a numeric status (0 means success). The client throws with error and request_id included, so logs are actionable.
  • path() instead of get(). Optional fields simply won't exist on some results; path() returns a missing node and asText("") gives you an empty string instead of an NPE.

Paginating

package dev.serpbase.examples;

public class Main {
    public static void main(String[] args) throws Exception {
        SerpClient client = new SerpClient(System.getenv("SERPBASE_API_KEY"));
        for (int page = 1; page <= 2; page++) {
            var results = client.searchPage("java httpclient example", page);
            if (results.isEmpty()) {
                break;              // no more results
            }
            for (var r : results) {
                System.out.printf("%d. %s%n   %s%n", r.rank(), r.title(), r.link());
            }
            Thread.sleep(1000);     // be polite between pages
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Keep Main in the same package (dev.serpbase.examples) so it can see SerpClient.

What it costs to run

1 credit per successful request, and the response's credits_charged is the source of truth for billing. New accounts start with 100 free searches, which is plenty to validate the client. If you hit 1029 RATE_LIMITED, you're pushing QPS or concurrency too hard — keep the loop serial and sleep between pages.

FAQ

Why not OkHttp or a full SDK? HttpClient is in the JDK, and the API surface here is one POST. Fewer moving parts in a service that only needs search results.

How do I get the optional fields like date or sitelinks? Add them to the record and read them with path(); treat missing values as absent rather than defaulting to something misleading.

Can I use virtual threads for many queries? You can, but respect the documented rate limits — a Semaphore capping in-flight requests plus a small delay between batches keeps you clear of 1029.

Compile it, set SERPBASE_API_KEY, and run Main — the same class drops into a Spring service or a CLI unchanged. All the parameters used here are in the API reference linked above.

Top comments (0)