ScreenshotRun lets Swift developers capture any webpage as a PNG, JPEG, WebP, AVIF, or PDF through a single URLSession request. The API works identically on iOS, macOS, watchOS, and server-side Swift. No WKWebView hacks, no offscreen rendering workarounds, no WebKit delegate chains.
Taking a screenshot of a remote webpage in a native Apple app is surprisingly hard. WKWebView can render HTML, but capturing its contents requires loading the page in an offscreen view, waiting for JavaScript to settle, adjusting the view's frame to match the content height, then calling drawHierarchy(in:afterScreenUpdates:). That code breaks across iOS versions, produces blank images on background threads, and cannot run on a server. A REST API call sidesteps the entire UIKit pipeline.
Capture your first screenshot
Register in the dashboard to get your API key. It appears on the settings screen after you verify your email. The free plan starts at 200 captures per month, no card on file.
This works in a Playground, a command-line tool, or any Swift project:
import Foundation
let apiKey = "YOUR_API_KEY"
var components = URLComponents(string: "https://api.screenshotrun.com/v1/screenshots/capture")!
components.queryItems = [
URLQueryItem(name: "url", value: "https://github.com"),
URLQueryItem(name: "format", value: "png"),
URLQueryItem(name: "response_type", value: "image"),
]
var request = URLRequest(url: components.url!)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = response as! HTTPURLResponse
guard httpResponse.statusCode == 200 else {
fatalError("Capture failed: HTTP \(httpResponse.statusCode)")
}
let fileURL = URL(fileURLWithPath: "github.png")
try data.write(to: fileURL)
print("Saved: github.png (\(data.count / 1024) KB)")
Swift 5.5+ async/await makes the call clean. URLSession.shared.data(for:) suspends the current task while the network request runs, then resumes with the image bytes. Our servers handle the headless Chrome rendering, execute JavaScript, and encode the image on the backend.
Reusable screenshot client
A struct that wraps the API and works anywhere in your app:
import Foundation
struct ScreenshotClient {
let apiKey: String
let session: URLSession
init(apiKey: String, session: URLSession = .shared) {
self.apiKey = apiKey
self.session = session
}
func capture(
url: String,
format: String = "png",
width: Int = 1280,
fullPage: Bool = false
) async throws -> Data {
var components = URLComponents(
string: "https://api.screenshotrun.com/v1/screenshots/capture"
)!
components.queryItems = [
URLQueryItem(name: "url", value: url),
URLQueryItem(name: "format", value: format),
URLQueryItem(name: "width", value: String(width)),
URLQueryItem(name: "full_page", value: String(fullPage)),
URLQueryItem(name: "response_type", value: "image"),
]
var request = URLRequest(url: components.url!)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
let status = (response as! HTTPURLResponse).statusCode
guard (200...299).contains(status) else {
throw ScreenshotError.httpError(status)
}
return data
}
}
enum ScreenshotError: Error {
case httpError(Int)
}
Usage:
let client = ScreenshotClient(
apiKey: ProcessInfo.processInfo.environment["SCREENSHOTRUN_KEY"]!
)
let imageData = try await client.capture(
url: "https://github.com",
format: "webp",
width: 1920,
fullPage: true
)
try imageData.write(to: URL(fileURLWithPath: "github-full.webp"))
print("Full-page WebP: \(imageData.count / 1024) KB")
The API key reads from an environment variable. On iOS you would store it in the Keychain or fetch it from your own backend instead of bundling it in the app binary.
Using screenshots in SwiftUI
Load a screenshot directly into a SwiftUI view. This pattern works for link previews and thumbnail grids. It also fits any UI that needs a live capture of a webpage:
import SwiftUI
struct WebScreenshotView: View {
let targetURL: String
@State private var image: UIImage?
@State private var isLoading = true
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
} else if isLoading {
ProgressView()
} else {
Image(systemName: "photo")
.foregroundColor(.secondary)
}
}
.task {
await loadScreenshot()
}
}
private func loadScreenshot() async {
let client = ScreenshotClient(
apiKey: ProcessInfo.processInfo.environment["SCREENSHOTRUN_KEY"]!
)
do {
let data = try await client.capture(url: targetURL, format: "jpeg", width: 1280)
image = UIImage(data: data)
} catch {
print("Screenshot failed: \(error)")
}
isLoading = false
}
}
The .task modifier launches the capture when the view appears and cancels it if the view disappears before the response arrives. For production apps, add caching (NSCache or a disk cache) so the same URL does not trigger a new API call on every view reload.
Vapor server-side integration
Server-side Swift through Vapor works well for screenshot proxy endpoints. Your iOS app calls your Vapor backend, which adds the API key and forwards the request. The key never ships in the app bundle.
import Vapor
func routes(_ app: Application) throws {
app.get("api", "screenshot") { req async throws -> Response in
guard let targetURL = req.query[String.self, at: "url"] else {
throw Abort(.badRequest, reason: "Missing url parameter")
}
let format = req.query[String.self, at: "format"] ?? "png"
let width = req.query[String.self, at: "width"] ?? "1280"
var components = URLComponents(
string: "https://api.screenshotrun.com/v1/screenshots/capture"
)!
components.queryItems = [
URLQueryItem(name: "url", value: targetURL),
URLQueryItem(name: "format", value: format),
URLQueryItem(name: "width", value: width),
URLQueryItem(name: "response_type", value: "image"),
]
var headers = HTTPHeaders()
headers.add(
name: .authorization,
value: "Bearer \(Environment.get("SCREENSHOTRUN_KEY")!)"
)
let apiResponse = try await req.client.get(
URI(string: components.url!.absoluteString),
headers: headers
)
guard apiResponse.status == .ok, let body = apiResponse.body else {
throw Abort(.init(statusCode: Int(apiResponse.status.code)))
}
let contentType: HTTPMediaType = format == "jpeg" ? .jpeg : .png
return Response(
status: .ok,
headers: ["Content-Type": contentType.serialize()],
body: .init(buffer: body)
)
}
}
Your app hits /api/screenshot?url=https://example.com&format=png and gets the image. Vapor's built-in HTTP client handles connection pooling. Add response middleware or Redis caching to avoid repeat captures for popular URLs.
API parameters
Each parameter goes into a URLQueryItem. These cover the most common Swift use cases:
| Parameter | Type | Default | Effect |
|---|---|---|---|
url | String | required | The webpage address to capture |
format | String | png | Output format: png, jpeg, webp, avif, or pdf |
width | Int | 1280 | How wide the browser window is (pixels) |
height | Int | 720 | How tall the browser window is (pixels) |
full_page | Bool | false | Render the complete page from top to bottom as a single image |
device | String | desktop | Emulated screen size: desktop, mobile, or tablet |
retina | Bool | false | Capture at 2x resolution (matches Retina displays) |
dark_mode | Bool | false | Tell Chrome to prefer dark color scheme before loading |
selector | String | Crop the screenshot to match a particular DOM element | |
delay | Int | 0 | Wait this many milliseconds for dynamic content to load |
block_cookies | Bool | false | Clear cookie-consent overlays before the capture runs |
block_ads | Bool | false | Filter out ad banners and ad-injected elements |
block_chats | Bool | false | Suppress Intercom-style chat popups and help widgets |
css | String | A stylesheet applied to the page right before the screenshot fires | |
js | String | Code that executes in the browser context once the DOM loads |
The API docs list every parameter alongside curl examples and JSON response shapes.
Dark mode, element clipping, and custom styles
Dark mode
Include dark_mode=true in your URLQueryItem list. The rendering engine switches to the dark color scheme media preference, so pages like GitHub, Hacker News, or MDN show their night palette without any extra JavaScript:
components.queryItems = [
URLQueryItem(name: "url", value: "https://github.com"),
URLQueryItem(name: "dark_mode", value: "true"),
URLQueryItem(name: "response_type", value: "image"),
]
Element clipping
Specify a CSS selector through the selector parameter. The renderer isolates that element, measures it, and returns an image trimmed to its exact pixel boundaries:
URLQueryItem(name: "url", value: "https://github.com/apple"),
URLQueryItem(name: "selector", value: ".js-repo-list"),
URLQueryItem(name: "response_type", value: "image"),
Custom styles
Drop in a custom stylesheet that loads before Chrome takes the screenshot. Useful for removing sticky headers, swapping a background color, or collapsing a promo section:
URLQueryItem(name: "url", value: "https://example.com"),
URLQueryItem(name: "css", value: ".promo-banner { display: none !important; }"),
URLQueryItem(name: "response_type", value: "image"),
These parameters compose naturally. Pass dark_mode, selector, and css together in the same URLQueryItem array. One network round trip returns a cropped, dark-themed screenshot with your stylesheet baked in.
Retry logic with structured concurrency
URLSession surfaces connection problems through thrown errors, but an HTTP 429 or 503 arrives as a valid response you have to inspect yourself. Adding a retry wrapper keeps transient failures from crashing the whole capture flow:
func captureWithRetry(
client: ScreenshotClient,
url: String,
maxRetries: Int = 2
) async throws -> Data {
for attempt in 0...maxRetries {
do {
return try await client.capture(url: url)
} catch ScreenshotError.httpError(429) {
try await Task.sleep(for: .seconds(5))
continue
} catch ScreenshotError.httpError(let code) where code >= 500 {
if attempt < maxRetries {
try await Task.sleep(for: .seconds(Double(attempt + 1)))
continue
}
throw ScreenshotError.httpError(code)
}
}
throw ScreenshotError.httpError(0)
}
HTTP 429 signals that you exceeded the plan's request ceiling. The wrapper sleeps for five seconds (in production, read the Retry-After header instead of guessing). A 5xx comes from the rendering cluster, so the code pauses longer on each successive attempt to give it time to recover. Status codes in the 400 range (expired key, missing url parameter) skip the loop entirely because the payload is the problem, not the server.
Screenshot API vs. WKWebView rendering
WKWebView can render HTML, and with some effort you can extract a screenshot from it. But WKWebView was designed for interactive browsing, not headless capture. Here is how the two approaches compare for Swift developers:
| WKWebView offscreen rendering | ScreenshotRun API | |
|---|---|---|
| Platform | iOS and macOS only (UIKit/AppKit required) | Any platform with URLSession (iOS, macOS, Linux, watchOS) |
| Server-side Swift | Not available. WebKit does not run on Linux | Works with Vapor, Hummingbird, or plain Swift on Linux |
| Background execution | WKWebView requires the main thread on iOS | Runs from any thread or actor |
| Full-page capture | Resize the view frame, hope contentSize is accurate, stitch manually | One parameter: full_page=true |
| JavaScript-heavy pages | Guess when JS is done, poll document.readyState, add arbitrary delays | The renderer waits for network idle automatically |
| Cookie consent popups | Write per-site dismiss JavaScript, inject it via evaluateJavaScript | Pass block_cookies=true |
| Reliability | Blank screenshots on iOS 17+, memory warnings kill the web process | Consistent output regardless of device or OS version |
| Cost | Free but high engineering effort | 200 captures on the house each month, paid plans when you scale |
WKWebView makes sense when you need to render trusted HTML inside your app (a rich text editor, a local preview). For capturing arbitrary external webpages as images, a REST call through URLSession is more reliable and works across every Swift platform including Linux servers.
iOS developers wire this into apps that show website thumbnails inside collection views. Vapor teams run it in background jobs to archive landing pages overnight. SwiftUI previews pull fresh screenshots of competitor homepages into internal dashboards.
FAQ#
Yes. The API is a standard REST endpoint, so any platform with URLSession can call it. That includes iOS, macOS, watchOS, tvOS, and server-side Swift on Linux.
WKWebView requires UIKit or AppKit, runs only on Apple platforms, needs the main thread on iOS, and cannot do full-page captures natively. The API handles rendering on our servers and returns image bytes through a simple HTTP call that works on any platform.
Yes. Call the API with async/await inside a .task modifier, convert the returned Data to UIImage, and bind it to a SwiftUI Image view. The article includes a complete SwiftUI example.
No. Shipping the key in an app binary exposes it to anyone who decompiles the app. Instead, create a server-side proxy endpoint (Vapor or any backend) that adds the key and forwards the request. Your iOS app calls your server, not the API directly.
Yes. Vapor and Hummingbird both have HTTP clients that call REST endpoints. The article includes a complete Vapor route handler that proxies screenshot requests.
The free plan includes 200 captures per month with access to every parameter. No credit card required to start. Paid plans are available for higher volume.