Swift is the language behind every iOS app, a growing force in server-side development with Vapor, and a first-class choice for command-line tools on macOS and Linux. If you are building a competitive intelligence app, a sales enrichment tool, or a security scanner in Swift, you need a reliable way to detect which technologies a website is running.
The StackPeek API gives you exactly that: send a URL, get back structured JSON listing every detected framework, CMS, analytics tool, CDN, and hosting provider. No WebKit rendering, no headless browser dependencies, no third-party SDKs to install. Just a single HTTP GET request using Foundation's URLSession.
This guide covers real, production-ready Swift code for every common pattern: basic URLSession calls, Codable struct definitions for type-safe parsing, modern async/await with Swift 5.5+, concurrent scanning with TaskGroup, a SwiftUI view for displaying results, and server-side Vapor middleware. Every example compiles and runs in Xcode or with swift run on the command line.
Before writing any networking code, define Swift structs that conform to Codable so JSONDecoder can automatically map the API response into type-safe values. This is the foundation every other example in this guide builds on.
import Foundation
/// A single detected technology from the StackPeek API.
struct TechDetection: Codable, Identifiable {
let name: String
let category: String
let confidence: Double
let version: String?
let website: String?
var id: String { name }
}
/// The top-level response from the StackPeek detect endpoint.
struct DetectResponse: Codable {
let url: String
let technologies: [TechDetection]
let scanTime: Int?
}
/// Wraps a detection result with domain context
/// for batch scanning operations.
struct ScanResult: Identifiable {
let id = UUID()
let domain: String
let response: DetectResponse?
let error: String?
}
Swift's Codable protocol eliminates all manual JSON parsing. Property names match the API's JSON keys exactly, so no CodingKeys enum is needed. Optional properties like version and website are declared with ? — if the key is absent from the response, the value is nil rather than causing a decoding error. The Identifiable conformance on TechDetection makes it ready for SwiftUI lists and ForEach loops.
Foundation's URLSession is the standard networking layer on every Apple platform. Here is the simplest possible call to the StackPeek API using a completion handler, compatible with Swift 5.0+ and every version of iOS and macOS:
import Foundation
let baseURL = "https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect"
func detectTechStack(
targetURL: String,
completion: @escaping (Result<DetectResponse, Error>) -> Void
) {
guard let encoded = targetURL.addingPercentEncoding(
withAllowedCharacters: .urlQueryAllowed
) else {
completion(.failure(URLError(.badURL)))
return
}
guard let url = URL(string: "\(baseURL)?url=\(encoded)") else {
completion(.failure(URLError(.badURL)))
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(URLError(.badServerResponse,
userInfo: ["statusCode": code])))
return
}
guard let data = data else {
completion(.failure(URLError(.zeroByteResource)))
return
}
do {
let result = try JSONDecoder().decode(
DetectResponse.self, from: data
)
completion(.success(result))
} catch {
completion(.failure(error))
}
}
task.resume()
}
// Usage
detectTechStack(targetURL: "https://linear.app") { result in
switch result {
case .success(let response):
print("Technologies detected on \(response.url):")
for tech in response.technologies {
let ver = tech.version.map { " v\($0)" } ?? ""
print(" \(tech.name)\(ver) — \(tech.category) "
+ "(\(Int(tech.confidence))%)")
}
case .failure(let error):
print("Error: \(error.localizedDescription)")
}
}
Key details: addingPercentEncoding(withAllowedCharacters:) encodes the target URL so special characters do not break the query string. The completion handler uses Swift's Result type to provide either a decoded response or a typed error. JSONDecoder handles all the parsing — you never manually extract values from a dictionary. The task.resume() call is easy to forget and will silently fail if omitted.
Swift 5.5 introduced async/await, which eliminates callback pyramids and makes asynchronous code read like synchronous code. If you are targeting iOS 15+, macOS 12+, or building server-side Swift, this is the preferred approach:
import Foundation
/// A lightweight client for the StackPeek tech detection API.
actor StackPeekClient {
private let baseURL = "https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect"
private let session: URLSession
private let decoder = JSONDecoder()
init(timeoutInterval: TimeInterval = 30) {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = timeoutInterval
config.timeoutIntervalForResource = 60
config.waitsForConnectivity = true
self.session = URLSession(configuration: config)
}
/// Detect the tech stack of a given URL.
func detect(url targetURL: String) async throws -> DetectResponse {
guard let encoded = targetURL.addingPercentEncoding(
withAllowedCharacters: .urlQueryAllowed
),
let url = URL(string: "\(baseURL)?url=\(encoded)") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("stackpeek-swift/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse,
userInfo: ["statusCode": httpResponse.statusCode])
}
return try decoder.decode(DetectResponse.self, from: data)
}
}
// Usage
let client = StackPeekClient()
do {
let result = try await client.detect(url: "https://stripe.com")
print("Detected \(result.technologies.count) technologies on \(result.url)")
for tech in result.technologies {
let ver = tech.version ?? "-"
print(" \(tech.name.padding(toLength: 20, withPad: " ", startingAt: 0))"
+ "\(tech.category.padding(toLength: 12, withPad: " ", startingAt: 0))"
+ "\(ver.padding(toLength: 10, withPad: " ", startingAt: 0))"
+ "\(Int(tech.confidence))%")
}
} catch {
print("Detection failed: \(error)")
}
The actor keyword makes StackPeekClient thread-safe by default — Swift's concurrency runtime ensures that only one task accesses the actor's mutable state at a time. The async throws function signature means callers use try await, and errors propagate naturally through Swift's standard error handling. No callbacks, no DispatchQueue hopping, no retain cycles to worry about.
The waitsForConnectivity configuration tells URLSession to wait for a network connection rather than failing immediately when the device is offline — useful for mobile apps where connectivity is intermittent.
Swift's structured concurrency makes concurrent scanning clean and safe. TaskGroup manages child tasks automatically — if one fails or the parent task is cancelled, all child tasks are cancelled too. No manual thread management required.
import Foundation
/// Scan multiple domains concurrently using TaskGroup.
/// Returns results in the order they complete (not input order).
func scanAll(
domains: [String],
maxConcurrency: Int = 5
) async -> [ScanResult] {
let client = StackPeekClient()
return await withTaskGroup(of: ScanResult.self,
returning: [ScanResult].self) { group in
var results: [ScanResult] = []
var index = 0
// Seed the group with initial tasks up to maxConcurrency
for _ in 0..<min(maxConcurrency, domains.count) {
let domain = domains[index]
group.addTask {
do {
let response = try await client.detect(
url: "https://\(domain)"
)
return ScanResult(domain: domain,
response: response, error: nil)
} catch {
return ScanResult(domain: domain,
response: nil,
error: error.localizedDescription)
}
}
index += 1
}
// As each task completes, add the next domain
for await result in group {
results.append(result)
if index < domains.count {
let domain = domains[index]
group.addTask {
do {
let response = try await client.detect(
url: "https://\(domain)"
)
return ScanResult(domain: domain,
response: response, error: nil)
} catch {
return ScanResult(domain: domain,
response: nil,
error: error.localizedDescription)
}
}
index += 1
}
}
return results
}
}
// Usage
let domains = [
"stripe.com", "linear.app", "vercel.com", "notion.so",
"figma.com", "github.com", "shopify.com", "slack.com",
"gitlab.com", "netlify.com"
]
let start = Date()
let results = await scanAll(domains: domains, maxConcurrency: 5)
let elapsed = Date().timeIntervalSince(start)
print("Scanned \(results.count) domains in \(String(format: "%.1f", elapsed))s\n")
for r in results {
if let error = r.error {
print(" \(r.domain.padding(toLength: 18, withPad: " ", startingAt: 0))"
+ "ERROR: \(error)")
} else if let resp = r.response {
let techNames = resp.technologies.prefix(5).map(\.name)
let extra = resp.technologies.count > 5
? " (+\(resp.technologies.count - 5) more)" : ""
print(" \(r.domain.padding(toLength: 18, withPad: " ", startingAt: 0))"
+ "\(techNames.joined(separator: ", "))\(extra)")
}
}
This implementation uses a sliding window pattern: it seeds the TaskGroup with up to maxConcurrency tasks, then adds a new task each time one completes. This keeps exactly maxConcurrency tasks running at all times without exceeding the limit. The pattern avoids overwhelming the API while maximizing throughput — scanning 10 domains with a concurrency of 5 takes roughly the time of 2 sequential scans.
Because TaskGroup is structured, cancellation propagates automatically. If you wrap this in a SwiftUI .task modifier and the user navigates away, every in-flight scan is cancelled cleanly.
If you are building an iOS or macOS app, here is a complete SwiftUI view that lets users enter a URL, scan it, and see detected technologies in a polished list:
import SwiftUI
struct TechStackView: View {
@State private var targetURL = "https://stripe.com"
@State private var technologies: [TechDetection] = []
@State private var isLoading = false
@State private var errorMessage: String?
private let client = StackPeekClient()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// URL input bar
HStack {
TextField("Enter website URL", text: $targetURL)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
Button {
Task { await scan() }
} label: {
if isLoading {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "magnifyingglass")
}
}
.buttonStyle(.borderedProminent)
.disabled(isLoading || targetURL.isEmpty)
}
.padding()
if let error = errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.padding(.horizontal)
}
// Results list
List(technologies) { tech in
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(tech.name)
.font(.headline)
if let version = tech.version {
Text("v\(version)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text(tech.category)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(tech.confidence))%")
.font(.system(.body, design: .monospaced))
.foregroundStyle(
tech.confidence >= 90 ? .green :
tech.confidence >= 70 ? .orange : .red
)
}
}
.listStyle(.insetGrouped)
}
.navigationTitle("StackPeek")
}
}
private func scan() async {
isLoading = true
errorMessage = nil
technologies = []
do {
let result = try await client.detect(url: targetURL)
technologies = result.technologies.sorted {
$0.confidence > $1.confidence
}
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
The view uses the StackPeekClient actor defined earlier, so all networking is thread-safe. The Task block inside the button action bridges from synchronous SwiftUI event handling into async code. Results are sorted by confidence score so the most certain detections appear first. The confidence percentage is color-coded: green for 90%+, orange for 70-89%, and red below 70%.
This view works on iOS, iPadOS, and macOS with no platform-specific code. Add it to your app by placing TechStackView() in your navigation hierarchy or using it as a sheet. The NavigationStack wrapper provides a title bar and supports push navigation if you want to add detail views for each technology.
Swift runs on the server too. If you are building a web application with Vapor, you can add middleware that automatically detects the tech stack of referring websites. Every time a visitor arrives from another site, you learn what that site is built with — useful for analytics, competitive intelligence, and lead qualification.
import Vapor
import Foundation
/// Vapor middleware that detects the tech stack of referring websites.
/// Results are cached in-memory with a configurable TTL.
final class TechStackMiddleware: AsyncMiddleware {
private let apiBase = "https://us-central1-todd-agent-prod.cloudfunctions.net"
+ "/stackpeekApi/api/v1/detect"
private let cache = TechStackCache()
func respond(
to request: Request,
chainingTo next: AsyncResponder
) async throws -> Response {
// Extract and validate the Referer header
guard let referer = request.headers.first(name: .referer),
let refererURL = URL(string: referer),
let host = refererURL.host else {
return try await next.respond(to: request)
}
let domain = host.hasPrefix("www.")
? String(host.dropFirst(4)) : host
// Check cache first
if let cached = await cache.get(domain) {
request.storage.set(TechStackKey.self, to: cached)
return try await next.respond(to: request)
}
// Fire async detection in background — do not block the request
let app = request.application
Task {
do {
guard let encoded = "https://\(domain)"
.addingPercentEncoding(
withAllowedCharacters: .urlQueryAllowed
),
let url = URL(string: "\(apiBase)?url=\(encoded)") else {
return
}
let (data, _) = try await URLSession.shared.data(from: url)
let result = try JSONDecoder().decode(
DetectResponse.self, from: data
)
await cache.set(domain, response: result)
app.logger.info("Detected \(result.technologies.count) "
+ "techs on referrer \(domain)")
} catch {
app.logger.error("StackPeek error for \(domain): \(error)")
}
}
return try await next.respond(to: request)
}
}
/// Thread-safe in-memory cache using an actor.
actor TechStackCache {
private var entries: [String: CacheEntry] = [:]
private let ttl: TimeInterval = 86400 // 24 hours
struct CacheEntry {
let response: DetectResponse
let expiresAt: Date
}
func get(_ domain: String) -> DetectResponse? {
guard let entry = entries[domain],
Date() < entry.expiresAt else {
return nil
}
return entry.response
}
func set(_ domain: String, response: DetectResponse) {
entries[domain] = CacheEntry(
response: response,
expiresAt: Date().addingTimeInterval(ttl)
)
}
}
/// Storage key for attaching tech stack data to Vapor requests.
struct TechStackKey: StorageKey {
typealias Value = DetectResponse
}
// Register the middleware in configure.swift:
//
// app.middleware.use(TechStackMiddleware())
//
// Access in any route handler:
//
// func index(req: Request) async throws -> String {
// if let stack = req.storage.get(TechStackKey.self) {
// return "Referrer uses: \(stack.technologies.map(\.name))"
// }
// return "Welcome"
// }
The middleware fires the API call in a detached Task so it never adds latency to the incoming request. The actor-based cache is inherently thread-safe — no locks, no mutexes, no data races. Results are cached for 24 hours. On the first request from a new referrer, the cache miss means no tech stack data is available for that specific request, but every subsequent request from the same referring domain gets the cached result instantly.
Vapor's StorageKey pattern lets you attach the detection result directly to the request object, so any downstream route handler can access it with req.storage.get(TechStackKey.self) without coupling to the middleware implementation.
If you have looked at Wappalyzer's API pricing for a Swift project, here is how the two compare:
| Feature | StackPeek | Wappalyzer |
|---|---|---|
| Free tier | 100 scans/day | 50 scans/month |
| Paid plan (entry) | $9/month | $250/month |
| Scans on entry plan | 5,000/month | 50,000/month |
| Pro plan | $29/month (25,000 scans) | $450/month (100,000 scans) |
| Cost per scan (entry) | $0.0018 | $0.005 |
| API key required | No (free tier) | Yes |
| Technologies detected | 120+ | 1,400+ |
| Response format | JSON (Codable-ready) | JSON |
StackPeek is 28x cheaper than Wappalyzer at the entry tier. For most Swift projects — iOS apps with tech lookup features, server-side lead enrichment, macOS developer 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.
Here are the most common scenarios where Swift developers use tech stack detection:
package.dependencies line.Start detecting tech stacks from Swift today
100 free scans/day. No API key required. JSON response works natively with Codable.
Try StackPeek Free →The fastest way to test StackPeek from Swift requires zero dependencies — just Foundation:
Codable struct definitions and the async/await client into your main.swift@main entry point with a simple scan callFor a Swift Package Manager project, the same code works with swift run on macOS or Linux — no Xcode required. Create a Package.swift, drop the code into Sources/, and run from the terminal.
From there, add TaskGroup for concurrent scanning, wrap the client in a SwiftUI view for a visual interface, or integrate it as Vapor middleware for server-side detection. The API is the same regardless of how you call it — a single GET request with a URL query parameter that returns Codable-compatible JSON.
For production deployments, add caching with an actor-based in-memory store or UserDefaults for persistence across app launches. Add retry logic with exponential backoff for transient network failures. Use TaskGroup with a sliding window for concurrent scanning without overwhelming the API. Every pattern in this guide is copy-paste ready and compiles with Swift 5.5+.