How to Convert URLs to PDF with an API (React, Kotlin, Java, Swift)
The Simplest Way to Generate PDFs from URLs
Generating a PDF from a webpage sounds simple until you actually try to build it. Headless browsers eat memory. Lazy-loaded images break. Cookie banners end up in the output. Sticky headers repeat on every page.
EnConvert's url-to-pdf endpoint handles all of this for you. You send a URL, and you get back a perfectly rendered PDF. Cookie banners are dismissed automatically. Lazy content is loaded by scrolling through the entire page. Images are fully rendered before capture. Sticky headers are detected and handled.
One API call. One clean PDF. Let's integrate it.
Before You Start
You'll need an EnConvert API key. Here's how to get one:
Create a free account at enconvert.com
From your dashboard, go to the API Keys section
Click Create New Key
For server-side use (Kotlin, Java, Swift backend), choose Private Key
For browser-side use (React), choose Public Key — you'll need to set your allowed domains and select
url-to-pdfas an allowed endpointCopy the key immediately — it's shown only once
Private keys look like: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Public keys look like: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx
How the API Works
The conversion endpoint is:
POST /v1/convert/url-to-pdf
Server-side (Private Key): You send the request with your API key in the X-API-Key header along with a JSON body containing the URL. The API returns the PDF binary directly, with metadata in the response headers (X-Filename, X-File-Size, X-Conversion-Time).
Client-side (Public Key): Since you can't expose a private key in the browser, public keys follow a two-step flow. First, you exchange your public key for a short-lived JWT token (valid for 1 hour) through the /v1/auth/token endpoint. Then you use that JWT as a Bearer token for the conversion request. Cloudflare Turnstile verification is required during the token exchange to prevent bot abuse.
The API also supports several optional parameters to control the rendering:
load_media— Whether to load images and media (default:true)enable_scroll— Scroll through the page to trigger lazy-loaded content (default:true)handle_sticky_header— Detect and handle fixed/sticky navigation bars (default:true)handle_cookies— Automatically dismiss cookie consent banners (default:true)wait_for_images— Wait for all images to fully load before rendering (default:true)viewport_width— Browser viewport width in pixels (default:1920)viewport_height— Browser viewport height in pixels (default:1080)single_page— Render as a single continuous page (default:true)
Now let's look at real code.
React
React runs in the browser, so you'll use a public API key with the JWT token exchange flow. This matches exactly how EnConvert's own Playground works — exchange the key for a token, then call the conversion endpoint with that token.
import { useState, useRef } from "react";
import { Turnstile } from "@marsidev/react-turnstile";
const GATEWAY = "https://api.enconvert.com";
const PUBLIC_KEY = "pk_live_your_public_key_here";
const TURNSTILE_SITE_KEY = "your_turnstile_site_key";
export default function PdfConverter() {
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const turnstileRef = useRef(null);
const handleConvert = async () => {
setError("");
setLoading(true);
try {
// Step 1: Get Turnstile verification token
const turnstileToken = turnstileRef.current?.getResponse();
if (!turnstileToken) {
throw new Error("Verification pending — please try again.");
}
// Step 2: Exchange public API key for a short-lived JWT
const authRes = await fetch(`${GATEWAY}/v1/auth/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": PUBLIC_KEY,
},
body: JSON.stringify({ turnstile_token: turnstileToken }),
});
if (!authRes.ok) throw new Error("Authentication failed");
const { token } = await authRes.json();
// Step 3: Call the conversion endpoint with the JWT
const convRes = await fetch(`${GATEWAY}/v1/convert/url-to-pdf`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url }),
});
if (!convRes.ok) {
const errData = await convRes.json().catch(() => ({}));
throw new Error(errData.detail || "Conversion failed");
}
// Step 4: Handle the response
const contentType = convRes.headers.get("Content-Type") || "";
if (contentType.includes("application/json")) {
// Response contains a presigned download URL
const data = await convRes.json();
window.open(data.presigned_url, "_blank");
} else {
// Response is the PDF binary — create a download link
const blob = await convRes.blob();
const blobUrl = URL.createObjectURL(blob);
const filename =
convRes.headers.get("X-Filename") || "converted.pdf";
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
turnstileRef.current?.reset();
}
};
return (
<div>
<input
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
options={{ size: "invisible" }}
/>
<button onClick={handleConvert} disabled={loading || !url.trim()}>
{loading ? "Converting..." : "Convert to PDF"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
What's happening here:
The Turnstile widget runs invisibly in the background — no user friction
The public key is exchanged for a JWT that expires in 1 hour
The conversion response either returns the PDF directly as binary or a JSON object with a
presigned_urlfor downloadingAfter the conversion completes, the Turnstile widget resets for the next request
Handling slow conversions: Some heavy pages can take longer than 60 seconds to render. If the request times out (returns a 504 or the network connection drops), you can poll for the result using a job_id. Pass a job_id (use crypto.randomUUID()) in your conversion request body, then poll GET /v1/convert/status/{jobId} every 5 seconds until the status is "success" or "failed".
Kotlin
Kotlin is commonly used for Android apps and server-side JVM applications. With a private API key, the integration is clean and direct.
import java.io.File
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
fun convertUrlToPdf(url: String, outputPath: String) {
val client = HttpClient.newHttpClient()
val requestBody = """{"url": "$url"}"""
val request = HttpRequest.newBuilder()
.uri(URI.create("https://api.enconvert.com/v1/convert/url-to-pdf"))
.header("X-API-Key", "sk_live_your_private_key_here")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofByteArray())
if (response.statusCode() != 200) {
throw RuntimeException("Conversion failed: ${String(response.body())}")
}
val filename = response.headers()
.firstValue("X-Filename")
.orElse("converted.pdf")
File(outputPath, filename).writeBytes(response.body())
println("PDF saved: $outputPath/$filename")
}
fun main() {
convertUrlToPdf("https://example.com", "./output")
}
For Android apps, you'll want to run this off the main thread. Here's how it looks with Kotlin coroutines and OkHttp:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.util.concurrent.TimeUnit
suspend fun convertUrlToPdf(url: String, outputFile: File) {
withContext(Dispatchers.IO) {
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
val json = """{"url": "$url"}"""
val body = json.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("https://api.enconvert.com/v1/convert/url-to-pdf")
.addHeader("X-API-Key", "sk_live_your_private_key_here")
.post(body)
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
throw Exception("Conversion failed: ${response.body?.string()}")
}
val pdfBytes = response.body?.bytes()
?: throw Exception("Empty response")
outputFile.writeBytes(pdfBytes)
}
}
A note on timeouts: URL-to-PDF conversions involve rendering full web pages with JavaScript, images, and dynamic content. Set your read timeout to at least 120 seconds to accommodate complex pages. The default EnConvert server timeout is 300 seconds.
Java
Java's HttpClient (Java 11+) makes this straightforward. No external dependencies needed.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
public class PdfConverter {
private static final String API_KEY = "sk_live_your_private_key_here";
private static final String ENDPOINT = "https://api.enconvert.com/v1/convert/url-to-pdf";
public static Path convertUrlToPdf(String url, String outputDir)
throws IOException, InterruptedException {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
String jsonBody = String.format("{\"url\": \"%s\"}", url);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(ENDPOINT))
.header("X-API-Key", API_KEY)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(120))
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<byte[]> response = client.send(
request, HttpResponse.BodyHandlers.ofByteArray()
);
if (response.statusCode() != 200) {
throw new IOException(
"Conversion failed (" + response.statusCode() + "): "
+ new String(response.body())
);
}
String filename = response.headers()
.firstValue("X-Filename")
.orElse("converted.pdf");
Path outputPath = Path.of(outputDir, filename);
Files.write(outputPath, response.body());
System.out.println("PDF saved: " + outputPath);
return outputPath;
}
public static void main(String[] args) throws Exception {
convertUrlToPdf("https://example.com", "./output");
}
}
With Spring Boot, you might want to expose this as a service endpoint so your users can trigger conversions from your own application:
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
@RestController
@RequestMapping("/api/pdf")
public class PdfController {
private static final String API_KEY = "sk_live_your_private_key_here";
private static final String ENDPOINT = "https://api.enconvert.com/v1/convert/url-to-pdf";
private final RestTemplate restTemplate = new RestTemplate();
@PostMapping("/from-url")
public ResponseEntity<byte[]> convertUrl(@RequestParam String url) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-API-Key", API_KEY);
headers.setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"url\": \"%s\"}", url);
HttpEntity<String> request = new HttpEntity<>(body, headers);
ResponseEntity<byte[]> response = restTemplate.exchange(
ENDPOINT, HttpMethod.POST, request, byte[].class
);
String filename = response.getHeaders().getFirst("X-Filename");
if (filename == null) filename = "converted.pdf";
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(response.getBody());
}
}
This creates a /api/pdf/from-url?url=https://example.com endpoint on your own server that proxies the conversion through EnConvert — keeping your API key safely on the server while your frontend gets a direct PDF download.
Swift
For iOS and macOS apps, Swift's URLSession handles the conversion cleanly. Here's how to do it with async/await (Swift 5.5+):
import Foundation
struct PdfConverter {
static let apiKey = "sk_live_your_private_key_here"
static let endpoint = URL(string: "https://api.enconvert.com/v1/convert/url-to-pdf")!
static func convertUrlToPdf(url: String) async throws -> (Data, String) {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 120
let body: [String: Any] = ["url": url]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard httpResponse.statusCode == 200 else {
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
throw NSError(
domain: "PdfConverter",
code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Conversion failed: \(errorMessage)"]
)
}
let filename = httpResponse.value(forHTTPHeaderField: "X-Filename") ?? "converted.pdf"
return (data, filename)
}
}
Using it in a SwiftUI view:
import SwiftUI
struct ContentView: View {
@State private var url = ""
@State private var isConverting = false
@State private var message = ""
var body: some View {
VStack(spacing: 20) {
TextField("Enter URL", text: $url)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.keyboardType(.URL)
Button(isConverting ? "Converting..." : "Convert to PDF") {
Task { await convert() }
}
.disabled(url.isEmpty || isConverting)
if !message.isEmpty {
Text(message)
.foregroundColor(.secondary)
}
}
.padding()
}
func convert() async {
isConverting = true
message = ""
do {
let (pdfData, filename) = try await PdfConverter.convertUrlToPdf(url: url)
// Save to Documents directory
let documentsUrl = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first!
let fileUrl = documentsUrl.appendingPathComponent(filename)
try pdfData.write(to: fileUrl)
message = "Saved: \(filename) (\(pdfData.count / 1024) KB)"
} catch {
message = "Error: \(error.localizedDescription)"
}
isConverting = false
}
}
For sharing the PDF with the user (email, AirDrop, Files app), wrap the file URL in a UIActivityViewController on iOS or use NSSharingServicePicker on macOS after saving.
What Happens Under the Hood
When your request hits the url-to-pdf endpoint, EnConvert doesn't just take a quick snapshot. Here's the full rendering pipeline:
1. A real browser opens your URL — Powered by Playwright, a full Chromium instance navigates to the page and executes all JavaScript, just like a real visitor.
2. Cookie banners are dismissed — The system detects consent popups from major providers like OneTrust, Cookiebot, Usercentrics, and Didomi. It finds and clicks the accept or dismiss button so the banner doesn't appear in your PDF. It even checks inside iframes for embedded consent managers.
3. The page is scrolled top to bottom — In 120-pixel increments, the system scrolls through the entire page. This triggers lazy-loaded content, deferred images, and scroll-activated animations that would otherwise be missing from the output.
4. Images are verified — Every <img> tag is checked to confirm the image has fully loaded. The system waits up to 5 seconds per image, with a layout stabilization pause to prevent content shift.
5. Sticky headers are handled — Fixed-position navigation bars, sticky headers, and position: sticky elements are detected. The page scrolls back to the top so these elements render correctly in the final PDF without repeating on every page.
6. Viewport-relative units are normalized — CSS units like vh, svh, and dvh are converted to fixed pixel values so the PDF layout matches what you'd see in a real browser window.
7. The PDF is rendered and stored — The final PDF is generated, uploaded to secure cloud storage, and returned to you — either as a direct binary download or as a presigned URL.
All of this happens automatically. You don't configure any of it.
Handling Edge Cases
Long-Running Conversions
Complex pages with heavy JavaScript or many images can take over 60 seconds. If you're calling the API from an environment with proxy timeouts (like many cloud platforms), pass a job_id in your request body and poll for the result:
// Kotlin example with polling fallback
val jobId = UUID.randomUUID().toString()
val requestBody = """{"url": "$url", "job_id": "$jobId"}"""
// If the request times out or returns 504...
// Poll GET /v1/convert/status/{jobId} every 5 seconds
The status endpoint returns "processing", "success" (with a presigned_url), or "failed" (with an error message).
Batch Conversions
Need to convert multiple URLs at once? The API supports batch processing — send an array of URLs and receive a ZIP file with all the PDFs, or get them processed asynchronously with webhook notifications when the batch completes.
Webhook Notifications
For asynchronous workflows, provide a callback_url in your request. When the conversion finishes, EnConvert sends a POST request to your URL with the job status, file metadata, and download links — no polling required.
Private Key vs. Public Key: Which Should You Use?
Use a Private Key when:
You're calling the API from a backend server (Kotlin, Java, Swift server-side, Node.js server)
The key stays on your server and is never exposed to users
You want direct access without CAPTCHA verification
Use a Public Key when:
You're calling the API from a browser (React, Vue, Angular, vanilla JavaScript)
The key is visible in your frontend source code
You set allowed domains so the key only works from your website
You select specific endpoints the key can access (like only
url-to-pdf)Cloudflare Turnstile runs during token exchange to block bots
Public keys are designed to be safe even when visible. They're locked to your domains, restricted to your chosen endpoints, and require CAPTCHA verification before issuing a JWT. Even if someone copies the key, they can't use it from an unauthorized domain.
Try It Before You Build
Not ready to write code yet? EnConvert has an interactive Playground where you can test URL-to-PDF and all other conversion endpoints right in your browser. Enter a URL, hit convert, and see the result — including a live PDF preview. No account or API key needed.
You can also create an embeddable widget from your dashboard — just pick url-to-pdf as the endpoint, set your domain, and paste the generated iframe code into any HTML page. Your users get a working converter on your site without you writing any integration code.
Getting Started
Sign up for free at enconvert.com
Create a project and generate your API key
Copy one of the code examples above
Replace the API key placeholder with your real key
Run it
Your first URL-to-PDF conversion is one request away.