Java powers enterprise backends, microservices architectures, Android apps, and data processing pipelines at a scale few other languages match. If your Java application needs to detect what technologies a website is running — its framework, CMS, analytics tools, CDN, and hosting provider — you want an API that returns clean JSON you can deserialize into POJOs with Jackson or Gson. No headless browsers, no Selenium WebDriver, no JavaScript rendering.
The StackPeek API gives you exactly that: send a URL, get back structured JSON listing every detected technology with its category, confidence score, and version when detectable. It works with java.net.http.HttpClient, Spring WebClient, Quarkus REST Client, Micronaut HTTP Client, or any HTTP library in the Java ecosystem. One GET request, one JSON response, zero complexity.
This guide covers production-ready Java code for every common pattern: basic detection with the built-in HttpClient, a Spring Boot service with WebClient and scheduled scanning, a Quarkus REST Client with MicroProfile annotations, a reactive Micronaut HTTP Client, concurrent batch scanning with CompletableFuture, and a polished CLI tool with Picocli. Every example targets Java 17+ and runs against the live API with no API key required on the free tier.
In the enterprise Java ecosystem, technology detection is a building block for workflows that span sales, security, and competitive intelligence. When you can programmatically identify what frameworks and services a website uses, you unlock automation that manual browser extensions cannot match.
Common scenarios where Java developers reach for tech stack detection:
@Scheduled annotation and alert your team via Slack webhook.The API is a single GET endpoint. You pass the target URL as a query parameter and receive a JSON response containing every detected technology with its category, confidence score, and version when detectable.
GET https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect?url=https://stripe.com
{
"url": "https://stripe.com",
"technologies": [
{
"name": "React",
"category": "JavaScript Framework",
"confidence": 95,
"version": "18.2.0",
"website": "https://reactjs.org"
},
{
"name": "Next.js",
"category": "Web Framework",
"confidence": 90,
"version": "13.4",
"website": "https://nextjs.org"
}
],
"scanTime": 1240
}
The free tier gives you 100 scans per day with no API key required. Paid plans start at $9/month for 5,000 scans. The response is standard JSON that Jackson's ObjectMapper or Gson's fromJson deserializes into typed Java objects — no custom parsing, no XML, no pagination tokens.
Java 11 introduced java.net.http.HttpClient as a modern, built-in HTTP client that replaces the legacy HttpURLConnection. It supports HTTP/2, async operations, and is part of the JDK — no external dependencies for the HTTP call itself. We will use Jackson for JSON deserialization since it is the de facto standard in the Java ecosystem.
First, define the response model:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record StackPeekResponse(
String url,
List<Technology> technologies,
long scanTime
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Technology(
String name,
String category,
int confidence,
String version,
String website
) {}
}
Now the client that calls the API:
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
public class StackPeekClient {
private static final String BASE_URL =
"https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect";
private final HttpClient httpClient;
private final ObjectMapper mapper;
public StackPeekClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.mapper = new ObjectMapper();
}
public StackPeekResponse detect(String targetUrl) throws Exception {
String encoded = URLEncoder.encode(targetUrl, StandardCharsets.UTF_8);
URI uri = URI.create(BASE_URL + "?url=" + encoded);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() != 200) {
throw new RuntimeException(
"StackPeek API returned status " + response.statusCode()
);
}
return mapper.readValue(response.body(), StackPeekResponse.class);
}
public static void main(String[] args) throws Exception {
var client = new StackPeekClient();
var result = client.detect("https://stripe.com");
System.out.printf("Detected %d technologies on %s (in %dms)%n",
result.technologies().size(), result.url(), result.scanTime());
for (var tech : result.technologies()) {
System.out.printf(" %-20s %-20s v%-10s %d%%%n",
tech.name(), tech.category(),
tech.version() != null ? tech.version() : "-",
tech.confidence());
}
}
}
Key details: HttpClient is thread-safe and should be reused across requests — creating a new instance for every call wastes resources. The URLEncoder.encode() call handles special characters in the target URL. Java records (Java 16+) give you immutable data classes with zero boilerplate. Jackson's @JsonIgnoreProperties(ignoreUnknown = true) ensures forward compatibility when the API adds new fields.
Add Jackson to your pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
Spring Boot is the dominant framework for Java microservices. Here is a complete integration using WebClient (the reactive HTTP client that replaces RestTemplate), a service layer with caching, a REST controller, and a scheduled scanner that runs periodically.
The service class:
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
@Service
public class StackPeekService {
private static final String BASE_URL =
"https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect";
private final WebClient webClient;
public StackPeekService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl(BASE_URL)
.build();
}
@Cacheable(value = "techStacks", key = "#targetUrl")
public StackPeekResponse detect(String targetUrl) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("url", targetUrl)
.build())
.retrieve()
.onStatus(status -> status.isError(), response ->
Mono.error(new RuntimeException(
"StackPeek API error: " + response.statusCode())))
.bodyToMono(StackPeekResponse.class)
.timeout(Duration.ofSeconds(30))
.block();
}
public Mono<StackPeekResponse> detectAsync(String targetUrl) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.queryParam("url", targetUrl)
.build())
.retrieve()
.bodyToMono(StackPeekResponse.class)
.timeout(Duration.ofSeconds(30));
}
}
The REST controller that exposes the detection endpoint:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class StackPeekController {
private final StackPeekService stackPeekService;
public StackPeekController(StackPeekService stackPeekService) {
this.stackPeekService = stackPeekService;
}
@GetMapping("/detect")
public ResponseEntity<StackPeekResponse> detect(
@RequestParam String url) {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return ResponseEntity.badRequest().build();
}
var result = stackPeekService.detect(url);
return ResponseEntity.ok(result);
}
}
A scheduled scanner that runs every six hours and logs technology changes:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class ScheduledTechScanner {
private static final Logger log =
LoggerFactory.getLogger(ScheduledTechScanner.class);
private final StackPeekService stackPeekService;
private static final List<String> MONITORED_DOMAINS = List.of(
"https://stripe.com",
"https://linear.app",
"https://vercel.com",
"https://notion.so"
);
public ScheduledTechScanner(StackPeekService stackPeekService) {
this.stackPeekService = stackPeekService;
}
@Scheduled(cron = "0 0 */6 * * *")
public void scanMonitoredDomains() {
log.info("Starting scheduled tech stack scan for {} domains",
MONITORED_DOMAINS.size());
for (String domain : MONITORED_DOMAINS) {
try {
var result = stackPeekService.detect(domain);
log.info("Scanned {} — {} technologies detected",
domain, result.technologies().size());
} catch (Exception e) {
log.error("Failed to scan {}: {}", domain, e.getMessage());
}
}
}
}
The @Cacheable annotation on the service method means repeated requests for the same domain return instantly from the cache without consuming an API call. Configure the cache TTL in application.yml to match the API's data freshness — 24 hours is a reasonable default since a website's tech stack rarely changes more than once a day. The detectAsync method returns a Mono for fully reactive pipelines where blocking is not acceptable.
Add the Spring WebFlux dependency to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Quarkus uses the MicroProfile REST Client specification, which lets you define API clients as annotated Java interfaces. The framework generates the HTTP client implementation at build time, producing native-image-compatible code with minimal runtime overhead.
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@Path("/stackpeekApi/api/v1")
@RegisterRestClient(configKey = "stackpeek-api")
public interface StackPeekRestClient {
@GET
@Path("/detect")
StackPeekResponse detect(@QueryParam("url") String targetUrl);
}
Configure the base URL in application.properties:
quarkus.rest-client.stackpeek-api.url=https://us-central1-todd-agent-prod.cloudfunctions.net
quarkus.rest-client.stackpeek-api.connect-timeout=10000
quarkus.rest-client.stackpeek-api.read-timeout=30000
Inject and use the client in any CDI bean:
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.rest.client.inject.RestClient;
@ApplicationScoped
public class TechStackService {
@Inject
@RestClient
StackPeekRestClient stackPeekClient;
public StackPeekResponse analyze(String url) {
return stackPeekClient.detect(url);
}
}
Expose it through a JAX-RS resource:
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
@Path("/api")
@Produces(MediaType.APPLICATION_JSON)
public class TechStackResource {
@Inject
TechStackService techStackService;
@GET
@Path("/detect")
public StackPeekResponse detect(@QueryParam("url") String url) {
if (url == null || url.isBlank()) {
throw new BadRequestException("Missing 'url' query parameter");
}
return techStackService.analyze(url);
}
}
The MicroProfile REST Client handles URL encoding, JSON serialization with JSON-B, error mapping, and connection pooling. You write zero HTTP boilerplate — the interface declaration is the entire client. Quarkus compiles this into a native binary with GraalVM in under 30 seconds, producing a container image that starts in under 50ms and uses 20MB of RAM. That is ideal for serverless deployments where cold start time matters.
Micronaut takes a similar declarative approach but uses its own compile-time annotation processing instead of MicroProfile. The @Client annotation defines an HTTP client interface that Micronaut implements at compile time with no reflection or runtime proxies.
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.QueryValue;
import io.micronaut.http.client.annotation.Client;
import reactor.core.publisher.Mono;
@Client("https://us-central1-todd-agent-prod.cloudfunctions.net")
public interface StackPeekClient {
@Get("/stackpeekApi/api/v1/detect")
Mono<StackPeekResponse> detect(@QueryValue("url") String targetUrl);
}
Use it in a controller with reactive scanning across multiple domains:
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.QueryValue;
import jakarta.inject.Inject;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
@Controller("/api")
public class TechStackController {
@Inject
StackPeekClient stackPeekClient;
@Get("/detect")
public Mono<StackPeekResponse> detect(@QueryValue String url) {
return stackPeekClient.detect(url);
}
@Get("/batch")
public Flux<StackPeekResponse> batchDetect(
@QueryValue List<String> urls) {
return Flux.fromIterable(urls)
.flatMap(url -> stackPeekClient.detect(url)
.onErrorResume(e -> Mono.empty()), 5);
}
}
The batchDetect endpoint accepts multiple URLs and scans them concurrently using Project Reactor's Flux.flatMap. The second argument to flatMap (5) controls the concurrency level — at most 5 HTTP requests are in flight at any time. Failed scans are silently dropped with onErrorResume; in production you would map errors to a result object instead. Micronaut's compile-time DI means there is no classpath scanning or reflection at startup, resulting in sub-second startup times even on large applications.
For batch scanning jobs outside of a framework context — standalone tools, scheduled tasks, data pipelines — Java's CompletableFuture with an ExecutorService provides fine-grained control over concurrency. This approach works with any Java 17+ project and has no framework dependencies beyond Jackson.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
public class BatchScanner {
private static final String BASE_URL =
"https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect";
private final HttpClient httpClient;
private final ObjectMapper mapper;
private final ExecutorService executor;
public BatchScanner(int concurrency) {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.mapper = new ObjectMapper();
this.executor = Executors.newFixedThreadPool(concurrency);
}
public record ScanResult(
String url,
boolean success,
StackPeekResponse data,
String error
) {}
private ScanResult scanOne(String targetUrl) {
try {
String encoded = URLEncoder.encode(
targetUrl, StandardCharsets.UTF_8);
URI uri = URI.create(BASE_URL + "?url=" + encoded);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 429) {
Thread.sleep(5000);
return scanOne(targetUrl); // retry once
}
if (response.statusCode() != 200) {
return new ScanResult(targetUrl, false, null,
"HTTP " + response.statusCode());
}
var data = mapper.readValue(
response.body(), StackPeekResponse.class);
return new ScanResult(targetUrl, true, data, null);
} catch (Exception e) {
return new ScanResult(targetUrl, false, null, e.getMessage());
}
}
public List<ScanResult> scan(List<String> urls) {
List<CompletableFuture<ScanResult>> futures = urls.stream()
.map(url -> CompletableFuture.supplyAsync(
() -> scanOne(url), executor))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.join();
return futures.stream()
.map(CompletableFuture::join)
.toList();
}
public void shutdown() {
executor.shutdown();
}
public static void main(String[] args) {
List<String> domains = List.of(
"https://stripe.com",
"https://linear.app",
"https://vercel.com",
"https://notion.so",
"https://figma.com",
"https://github.com",
"https://shopify.com",
"https://slack.com"
);
var scanner = new BatchScanner(5);
Instant start = Instant.now();
List<ScanResult> results = scanner.scan(domains);
Duration elapsed = Duration.between(start, Instant.now());
System.out.printf("Scanned %d domains in %.1fs%n",
domains.size(), elapsed.toMillis() / 1000.0);
long successful = results.stream()
.filter(ScanResult::success).count();
System.out.printf("Successful: %d, Failed: %d%n",
successful, results.size() - successful);
// Technology frequency analysis
Map<String, Long> techFrequency = results.stream()
.filter(ScanResult::success)
.flatMap(r -> r.data().technologies().stream())
.collect(Collectors.groupingBy(
StackPeekResponse.Technology::name,
Collectors.counting()));
System.out.println("\nMost common technologies:");
techFrequency.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue()
.reversed())
.limit(10)
.forEach(e -> System.out.printf(" %-25s %d sites%n",
e.getKey(), e.getValue()));
scanner.shutdown();
}
}
The CompletableFuture.supplyAsync submits each scan to the thread pool. The allOf().join() call blocks until every scan completes. With 5 threads scanning 8 domains, the total time is roughly the time of 2 sequential scans instead of 8. For Java 21+, replace Executors.newFixedThreadPool with Executors.newVirtualThreadPerTaskExecutor() to use virtual threads — you can then set concurrency to hundreds without worrying about OS thread limits.
Picocli is the most popular library for building Java command-line tools. It provides annotations for commands, arguments, and options with automatic help text, ANSI color output, and tab completion. Combined with GraalVM native image, you get a CLI tool with instant startup.
import com.fasterxml.jackson.databind.ObjectMapper;
import picocli.CommandLine;
import picocli.CommandLine.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.Callable;
@Command(
name = "stackpeek",
mixinStandardHelpOptions = true,
version = "stackpeek 1.0",
description = "Detect the technology stack of any website."
)
public class StackPeekCli implements Callable<Integer> {
private static final String BASE_URL =
"https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect";
@Parameters(index = "0", description = "URL to scan")
private String url;
@Option(names = {"-j", "--json"},
description = "Output raw JSON")
private boolean jsonOutput;
@Option(names = {"-c", "--min-confidence"},
description = "Minimum confidence threshold (0-100)",
defaultValue = "0")
private int minConfidence;
@Option(names = {"-t", "--timeout"},
description = "Request timeout in seconds",
defaultValue = "30")
private int timeout;
@Override
public Integer call() throws Exception {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}
var httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
String encoded = URLEncoder.encode(url, StandardCharsets.UTF_8);
URI uri = URI.create(BASE_URL + "?url=" + encoded);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(timeout))
.GET()
.build();
System.err.println("Scanning " + url + "...");
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.err.println("API error: HTTP " + response.statusCode());
return 1;
}
var mapper = new ObjectMapper();
var result = mapper.readValue(
response.body(), StackPeekResponse.class);
if (jsonOutput) {
System.out.println(
mapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(result));
return 0;
}
var techs = result.technologies().stream()
.filter(t -> t.confidence() >= minConfidence)
.toList();
System.out.printf("%nTechnologies on %s:%n%n", result.url());
System.out.printf(" %-22s %-20s %-10s %s%n",
"NAME", "CATEGORY", "VERSION", "CONFIDENCE");
System.out.printf(" %s%n", "-".repeat(66));
for (var tech : techs) {
String ver = tech.version() != null ? tech.version() : "-";
String color = tech.confidence() >= 80 ? "\u001b[32m"
: tech.confidence() >= 50 ? "\u001b[33m" : "\u001b[37m";
System.out.printf(" %-22s %-20s %-10s %s%d%%\u001b[0m%n",
tech.name(), tech.category(), ver,
color, tech.confidence());
}
System.out.printf("%n %d technologies detected%n", techs.size());
if (result.scanTime() > 0) {
System.out.printf(" Scan completed in %dms%n",
result.scanTime());
}
return 0;
}
public static void main(String[] args) {
int exitCode = new CommandLine(new StackPeekCli()).execute(args);
System.exit(exitCode);
}
}
Usage from the command line:
# Basic scan (auto-adds https://)
$ stackpeek stripe.com
# JSON output for piping to jq
$ stackpeek vercel.com --json | jq '.technologies[].name'
# Only high-confidence detections
$ stackpeek github.com --min-confidence 80
# Custom timeout
$ stackpeek slow-site.com --timeout 60
# Help text (auto-generated)
$ stackpeek --help
Add Picocli to your pom.xml:
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.6</version>
</dependency>
Picocli automatically generates --help and --version output from the annotations. The @Command(mixinStandardHelpOptions = true) adds those flags without any additional code. ANSI color codes highlight confidence levels in the terminal — green for high confidence, yellow for medium, white for low. For distribution, compile with GraalVM native image to produce a single binary with no JVM dependency and sub-10ms startup time.
If you have looked at Wappalyzer's API pricing for a Java project, here is how the two compare:
| Feature | StackPeek | Wappalyzer |
|---|---|---|
| Free tier | 100 scans/day | 50 scans/month |
| Starter plan | $9/month (5,000 scans) | $250/month (50,000 scans) |
| Pro plan | $29/month (25,000 scans) | $450/month (100,000 scans) |
| Cost per scan (starter) | $0.0018 | $0.005 |
| API key required | No (free tier) | Yes |
| Technologies detected | 120+ | 1,400+ |
| Java-native JSON | Jackson / Gson / JSON-B | Jackson / Gson / JSON-B |
| Framework integrations | Spring, Quarkus, Micronaut | Generic REST |
StackPeek is 28x cheaper than Wappalyzer at the entry tier. For most Java projects — Spring Boot microservices, Quarkus APIs, batch processing jobs, CLI tools — you need to detect the major frameworks, CMS platforms, and analytics tools. StackPeek covers these at a fraction of the cost. If you need to identify niche JavaScript libraries or obscure WordPress plugins, Wappalyzer's broader fingerprint database may justify the premium. But for 90% of use cases, StackPeek at $9/month vs Wappalyzer at $250/month is the obvious choice.
Start detecting tech stacks from Java today
100 free scans/day. No API key required. JSON deserializes natively with Jackson, Gson, or JSON-B.
View Pricing & Get Started →Java's type system, concurrency primitives, and mature framework ecosystem make it an excellent choice for building technology detection into production systems. The StackPeek API returns standard JSON that maps cleanly to Java records and POJOs — no custom deserialization logic, no XML parsing, no pagination handling.
Here is a summary of what each approach gives you:
@Cacheable integration, @Scheduled scanning. Best for Spring-based microservices and web applications.Mono/Flux return types. Best for high-throughput microservices with reactive pipelines.For production deployments, add a caching layer (Caffeine, Spring Cache, or a simple ConcurrentHashMap with TTL) to avoid redundant API calls for the same domain. Add retry logic with exponential backoff for transient network failures. Use bounded thread pools or virtual threads to control concurrency in batch jobs. Every pattern in this guide is copy-paste ready and runs on Java 17 or later.
StackPeek is one tool in a growing suite of developer APIs. If you find it useful, check out the rest: