In one sentence
A developer guide to converting HTML to PDF from C#, PHP and Java with the AIIPWORLD render API, with one tested code file per language.
Key facts
- Each file was run on 2026-10-11 against a local copy of the API: C# on .NET SDK 8.0.425, PHP 8.3.35 and Java on OpenJDK 17.0.20.1, with no packages or libraries to install.
- Flow: POST /v1/jobs with op html_to_pdf and the HTML, poll GET /v1/jobs/:id with a growing pause, then download the PDF from download_url without sending the API key.
- Polls count toward the per-minute limit of the key (Free 30, Starter 60, Pro 180, Business 600); the examples wait for the Retry-After header on a 429.
- A simple render costs 1 credit and the Free plan includes 100 credits a month.
Before you start
Each example sends a small HTML invoice to the render API and saves the PDF that comes back. You need an API key, which you create in the dashboard after you sign in. Keep the key out of your source code.
Whether you want to C# generate PDF from HTML, C# create PDF from HTML or C# convert HTML to PDF, the call is the same. PHP developers who search for PHP generate PDF from HTML, or for a PHP PDF generator, use the second file, and the Java file is a small Java PDF API client. Set the key in an environment variable named AIIPWORLD_API_KEY. The API address is https://api.aiipworld.com, and each file sets it as a constant at the top. Every request sends the key as a Bearer token in the Authorization header.
- Create an API key in the dashboard.
- Export AIIPWORLD_API_KEY before you run a file.
- A simple one-page render uses 1 credit.
How the API call works
Every example follows the same three steps. First, it posts a JSON body with the op html_to_pdf and your HTML in params. The API answers 202 with a job_id. Second, it polls GET /v1/jobs/ followed by that id until the status is succeeded, failed or timeout. Third, when the job succeeds, it downloads the PDF from download_url.
Polling starts at 0.5 seconds and grows by a factor of 1.5 until it reaches 5 seconds. Each poll is a request, and polls count toward the per-minute limit of your key. A fixed 0.5 second poll would send 120 requests a minute for one job, so the growing pause keeps the count lower.
If the API answers 429, the code reads the Retry-After header, waits, and tries again up to 3 times. The same retry runs for submits and polls.
C# HTML to PDF with HttpClient
The C# file uses only System.Net.Http and System.Text.Json, so you need no NuGet packages. Put Program.cs and a project file for net8.0 in one folder, then run dotnet run with your key set. The file creates one HttpClient for the whole process and a second one for the download.
The HTML is a raw string literal, so the invoice markup stays readable across lines. Errors become a RenderException that carries the HTTP status or the job status, and the program exits with code 1 when something fails. This is a working way to convert HTML to PDF in C# over plain HTTP.
// HTML to PDF with the aiipworld render API. .NET 8, no NuGet packages.
// Run: AIIPWORLD_API_KEY=... dotnet run (in a folder with this Program.cs and a .csproj)
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
const string Api = "https://api.aiipworld.com";
// One HttpClient for the whole process. Creating a new one per request exhausts sockets.
using var http = new HttpClient { BaseAddress = new Uri(Api), Timeout = TimeSpan.FromSeconds(60) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("AIIPWORLD_API_KEY"));
// A separate client for the download. download_url is already signed, so it must NOT carry your API key.
using var plain = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
var html = """
<!doctype html>
<html>
<body style="font-family: sans-serif">
<h1>Invoice INV-1001</h1>
<p>Bill to: Maple & Pine Co.</p>
<p>Rendered from C# with aiipworld.com</p>
</body>
</html>
""";
try
{
var job = await RenderHtmlToPdf(html, "invoice.pdf");
var result = job.GetProperty("result");
Console.WriteLine($"succeeded {result.GetProperty("pages").GetInt32()} page(s), " +
$"{result.GetProperty("bytes").GetInt32()} bytes -> invoice.pdf");
}
catch (RenderException e)
{
// e.Code is an HTTP status (400, 401, 402, 429 ...) or a job status ("failed", "timeout")
Console.Error.WriteLine($"{e.Code}: {e.Message}");
return 1;
}
return 0;
// Send one request. On 429 wait for Retry-After and try again (the polling calls count toward the limit too).
async Task<HttpResponseMessage> Send(Func<HttpRequestMessage> build, int retries = 3)
{
for (var attempt = 0; ; attempt++)
{
var res = await http.SendAsync(build());
if (res.StatusCode != HttpStatusCode.TooManyRequests || attempt >= retries) return res;
var wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
res.Dispose();
await Task.Delay(wait);
}
}
// Turn an error response into an exception. 401 = bad key, 402 = no credits left, 400 = bad request.
async Task<RenderException> ErrorFrom(HttpResponseMessage res)
{
var text = await res.Content.ReadAsStringAsync();
try
{
using var doc = JsonDocument.Parse(text);
var root = doc.RootElement;
var code = root.TryGetProperty("error", out var e) ? e.ToString() : "error";
var msg = root.TryGetProperty("message", out var m) ? $": {m}" : "";
return new RenderException((int)res.StatusCode, code + msg);
}
catch (JsonException)
{
return new RenderException((int)res.StatusCode, text.Length > 200 ? text[..200] : text);
}
}
async Task<JsonElement> RenderHtmlToPdf(string pageHtml, string path)
{
// 1. Submit. The API answers 202 with a job id.
var res = await Send(() => new HttpRequestMessage(HttpMethod.Post, "/v1/jobs")
{
Content = JsonContent.Create(new { op = "html_to_pdf", @params = new { html = pageHtml } }),
});
if (res.StatusCode != HttpStatusCode.Accepted) throw await ErrorFrom(res);
var jobId = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("job_id").GetString()!;
// 2. Poll: start at 0.5 s, grow to 5 s.
var pause = 500.0;
var deadline = DateTime.UtcNow.AddSeconds(120);
while (DateTime.UtcNow < deadline)
{
await Task.Delay((int)pause);
pause = Math.Min(pause * 1.5, 5000);
res = await Send(() => new HttpRequestMessage(HttpMethod.Get, $"/v1/jobs/{jobId}"));
if (!res.IsSuccessStatusCode) throw await ErrorFrom(res);
var job = await res.Content.ReadFromJsonAsync<JsonElement>();
var status = job.GetProperty("status").GetString();
if (status == "queued" || status == "running") continue;
if (status != "succeeded") // "failed": the error field says why
throw new RenderException(status!, job.GetProperty("error").ToString());
// 3. Download the PDF from the signed link.
var file = await plain.GetAsync(job.GetProperty("download_url").GetString());
file.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync(path, await file.Content.ReadAsByteArrayAsync());
return job;
}
throw new RenderException("client_timeout", $"job {jobId} did not finish in 120 s");
}
class RenderException : Exception
{
public string Code { get; }
public RenderException(int status, string detail) : base(detail) { Code = status.ToString(); }
public RenderException(string status, string detail) : base(detail) { Code = status; }
}Notice the second HttpClient without the Authorization header. That client handles the download, which keeps your key off the signed link. The Send helper also handles 429 responses for every call, including the polls.
PHP HTML to PDF with curl
The PHP file needs PHP 8.1 or later with the curl and json extensions, which are on by default. It needs no Composer packages. A small call function sends each request with cURL and reads Retry-After through a header callback, because cURL does not return headers in the body.
Run it with AIIPWORLD_API_KEY set and php html_to_pdf.php. The download uses CURLOPT_FOLLOWLOCATION, since cURL does not follow redirects unless you ask it to. In our run the download did not redirect, so that option was not exercised.
<?php
// HTML to PDF with the aiipworld render API. PHP 8.1+ with the curl and json extensions (both are on by default).
// Run: AIIPWORLD_API_KEY=... php html_to_pdf.php
const API = 'https://api.aiipworld.com';
class RenderError extends Exception
{
// $code_ is an HTTP status (400, 401, 402, 429 ...) or a job status ("failed", "timeout")
public function __construct(public string $code_, string $detail)
{
parent::__construct($detail);
}
}
/** Send one API request. On 429 wait for Retry-After and try again (polling calls count toward the limit too). */
function call(string $method, string $path, ?array $body = null, int $retries = 3): array
{
for ($attempt = 0;; $attempt++) {
$retryAfter = 1;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('AIIPWORLD_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, 'retry-after:') === 0) {
$retryAfter = max(1, (int) trim(substr($line, 12)));
}
return strlen($line);
},
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES));
}
$text = curl_exec($ch);
if ($text === false) {
throw new RenderError('network', curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status === 429 && $attempt < $retries) {
sleep($retryAfter);
continue;
}
return [$status, json_decode($text, true) ?? ['error' => substr($text, 0, 200)]];
}
}
/** Save a file from the signed download_url. No Authorization header: the link already carries its own signature. */
function download(string $url, string $path): int
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, // curl does not follow redirects unless you ask
CURLOPT_TIMEOUT => 60,
]);
$data = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($data === false || $status !== 200) {
throw new RenderError((string) $status, 'download failed');
}
file_put_contents($path, $data);
return strlen($data);
}
function renderHtmlToPdf(string $html, string $path, int $timeout = 120): array
{
// 1. Submit. The API answers 202 with a job id. 401 = bad key, 402 = no credits left, 400 = bad request.
[$status, $res] = call('POST', '/v1/jobs', ['op' => 'html_to_pdf', 'params' => ['html' => $html]]);
if ($status !== 202) {
throw new RenderError((string) $status, ($res['error'] ?? 'error') . (isset($res['message']) ? ': ' . $res['message'] : ''));
}
$jobId = $res['job_id'];
// 2. Poll: start at 0.5 s, grow to 5 s.
$pause = 0.5;
$deadline = microtime(true) + $timeout;
while (microtime(true) < $deadline) {
usleep((int) ($pause * 1_000_000));
$pause = min($pause * 1.5, 5);
[$status, $job] = call('GET', "/v1/jobs/$jobId");
if ($status !== 200) {
throw new RenderError((string) $status, $job['error'] ?? 'error');
}
if (in_array($job['status'], ['queued', 'running'], true)) {
continue;
}
if ($job['status'] !== 'succeeded') { // "failed": the error field says why
throw new RenderError($job['status'], (string) $job['error']);
}
// 3. Download the PDF.
download($job['download_url'], $path);
return $job;
}
throw new RenderError('client_timeout', "job $jobId did not finish in $timeout s");
}
$html = <<<HTML
<!doctype html>
<html>
<body style="font-family: sans-serif">
<h1>Invoice INV-1001</h1>
<p>Bill to: Maple & Pine Co.</p>
<p>Rendered from PHP with aiipworld.com</p>
</body>
</html>
HTML;
try {
$job = renderHtmlToPdf($html, 'invoice.pdf');
echo "succeeded {$job['result']['pages']} page(s), {$job['result']['bytes']} bytes -> invoice.pdf\n";
} catch (RenderError $e) {
fwrite(STDERR, "{$e->code_}: {$e->getMessage()}\n");
exit(1);
}The HTML is a PHP heredoc, so its line breaks are kept. The renderHtmlToPdf function returns the finished job, and the error class carries either an HTTP status or a job status. This is the same flow as the C# file, so you can compare the two directly.
Java HTML to PDF with java.net.http
This is the HTML to PDF Java example. The Java file uses only java.net.http and runs as a single file on JDK 17 or later. It has no dependencies, so you start it with java HtmlToPdf.java. A small reader pulls the string fields it needs out of the JSON answers. The file's comment says to use Jackson or Gson in a real project.
The HTML is a text block, which keeps multi-line markup readable. When you build JSON by hand, quotes, newlines and backslashes must be escaped, and the file includes a jsonString helper for that.
// HTML to PDF with the aiipworld render API.
// JDK 17+, no dependencies: java.net.http for HTTP and a tiny string-field reader for JSON.
// (In a real project use Jackson or Gson instead of field(); the HTTP calls stay the same.)
// Run: AIIPWORLD_API_KEY=... java HtmlToPdf.java
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;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class HtmlToPdf {
static final String API = "https://api.aiipworld.com";
static final String KEY = System.getenv("AIIPWORLD_API_KEY");
// One client for the whole process. It keeps connections alive.
static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
/** code is an HTTP status (400, 401, 402, 429 ...) or a job status ("failed", "timeout"). */
static class RenderException extends Exception {
final String code;
RenderException(String code, String detail) { super(detail); this.code = code; }
}
/** Value of a top-level-looking string field such as "status":"succeeded", or null if absent/null. */
static String field(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").matcher(json);
if (!m.find()) return null;
return m.group(1).replace("\\\"", "\"").replace("\\/", "/").replace("\\n", "\n").replace("\\\\", "\\");
}
static String jsonString(String s) {
StringBuilder b = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> b.append("\\\"");
case '\\' -> b.append("\\\\");
case '\n' -> b.append("\\n");
case '\r' -> b.append("\\r");
case '\t' -> b.append("\\t");
default -> { if (c < 0x20) b.append(String.format("\\u%04x", (int) c)); else b.append(c); }
}
}
return b.append('"').toString();
}
/** Send one API request. On 429 wait for Retry-After and try again (polling calls count toward the limit too). */
static HttpResponse<String> call(String method, String path, String body) throws Exception {
for (int attempt = 0; ; attempt++) {
HttpRequest.Builder rb = HttpRequest.newBuilder(URI.create(API + path))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + KEY)
.header("Content-Type", "application/json");
HttpRequest req = body == null ? rb.GET().build() : rb.POST(HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 429 && attempt < 3) {
Thread.sleep(1000L * Long.parseLong(res.headers().firstValue("retry-after").orElse("1")));
continue;
}
return res;
}
}
static RenderException errorFrom(HttpResponse<String> res) {
String err = field(res.body(), "error"), msg = field(res.body(), "message");
return new RenderException(String.valueOf(res.statusCode()),
(err != null ? err : "error") + (msg != null ? ": " + msg : ""));
}
static String renderHtmlToPdf(String html, Path out) throws Exception {
// 1. Submit. The API answers 202 with a job id. 401 = bad key, 402 = no credits left, 400 = bad request.
HttpResponse<String> res = call("POST", "/v1/jobs",
"{\"op\":\"html_to_pdf\",\"params\":{\"html\":" + jsonString(html) + "}}");
if (res.statusCode() != 202) throw errorFrom(res);
String jobId = field(res.body(), "job_id");
// 2. Poll: start at 0.5 s, grow to 5 s.
double pause = 500;
long deadline = System.nanoTime() + Duration.ofSeconds(120).toNanos();
while (System.nanoTime() < deadline) {
Thread.sleep((long) pause);
pause = Math.min(pause * 1.5, 5000);
res = call("GET", "/v1/jobs/" + jobId, null);
if (res.statusCode() != 200) throw errorFrom(res);
String status = field(res.body(), "status");
if (status.equals("queued") || status.equals("running")) continue;
if (!status.equals("succeeded")) throw new RenderException(status, field(res.body(), "error")); // "failed"
// 3. Download the PDF from the signed link. Plain GET: no Authorization header.
HttpResponse<byte[]> file = HTTP.send(
HttpRequest.newBuilder(URI.create(field(res.body(), "download_url"))).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
if (file.statusCode() != 200) throw new RenderException(String.valueOf(file.statusCode()), "download failed");
Files.write(out, file.body());
return res.body();
}
throw new RenderException("client_timeout", "job " + jobId + " did not finish in 120 s");
}
public static void main(String[] args) throws Exception {
String html = """
<!doctype html>
<html>
<body style="font-family: sans-serif">
<h1>Invoice INV-1001</h1>
<p>Bill to: Maple & Pine Co.</p>
<p>Rendered from Java with aiipworld.com</p>
</body>
</html>
""";
try {
Path out = Path.of("invoice.pdf");
renderHtmlToPdf(html, out);
System.out.println("succeeded, " + Files.size(out) + " bytes -> " + out);
} catch (RenderException e) {
System.err.println(e.code + ": " + e.getMessage());
System.exit(1);
}
}
}One static HttpClient is shared across calls and keeps connections alive. The download is a plain GET without the Authorization header, the same as in the other two files.
Example output
Options you will want next
Options go inside params, next to html. The defaults are A4 paper, a 10 mm margin on all four sides, and background printing on. You can set format, or width and height, along with landscape, margin with top, right, bottom and left, scale, and page_ranges.
Header and footer templates are options too. Set display_header_footer to true and add header_template and footer_template, each up to 64 KB of HTML. Use spans with the classes pageNumber and totalPages for page numbers, and leave room in the margin for them. The options wait_until, wait_for_selector and delay_ms help when a page loads late.
- Unknown option names are rejected, so check the spelling.
- The API docs list each option with its exact form.
Rendering a web address instead of HTML
To turn a public web page into a PDF, use the op url_to_pdf and put the address in params.url instead of html. The address must start with http or https. Private networks, localhost and cloud metadata addresses are refused, including after redirects.
JavaScript runs by default, so pages that build their content in the browser are rendered after their scripts run. There is no cookie option, so pages behind a login show the login screen. The PDF options from the previous section work here too.
Errors you will see and what they mean
A 401 with invalid_api_key means the key is wrong or missing. A 402 with insufficient_credits means the account has no credits left. A 400 means the request was not valid, often because an option name is unknown.
| What you see | What it means | What to do |
|---|---|---|
| 401 invalid_api_key | The key is wrong or missing | Check the AIIPWORLD_API_KEY variable and that the key was copied whole |
| 402 insufficient_credits | The account has no credits left | Add credits in the dashboard, or wait for the monthly credits |
| 400 | The request body is not valid | Check the JSON and the option names; the error text names the problem |
| 429 | Too many requests this minute | Wait for the Retry-After header, then retry; the examples already do |
| failed, invalid_input | An option name is unknown or a value is out of range | Read the error text, fix the option, submit again |
| failed, ssrf_blocked | The address is not allowed (private network, localhost, metadata) | Use a public http or https address |
| timeout | The job ran past its time limit | Simplify the page or remove long waits |
A job can also end with status failed or timeout, and its error field says why. In our tests, adding an unknown option produced "failed: invalid_input: params: Unrecognized key(s) in object: 'colour'". Other error classes are ssrf_blocked, navigation_failed, output_limit, memory_limit and timeout.
- Credits for failed and timed-out jobs are returned.
- The examples stop polling after 120 seconds and report a client-side timeout.
Things to watch
Use one HttpClient for the whole process in C#. Creating a new one for each request can exhaust sockets. Use a second client for the download, because the first one carries the Bearer header as a default.
Do not send your API key to download_url. The link is already signed, and it does not need the header.
- Polling counts toward the rate limit: Free 30, Starter 60, Pro 180 and Business 600 requests per minute.
- In Java, escape quotes, newlines and backslashes yourself when you build JSON by hand.
- In PHP, read Retry-After through a header callback, since cURL does not return headers in the body.
Tested versions and how we tested
We ran each file on 2026-10-11 with .NET SDK 8.0.425, PHP 8.3.35 and OpenJDK 17.0.20.1. Only the API address differed, because the runs used a local copy of the gateway and engine. Each run produced a one-page PDF of 15 to 15.5 KB that starts with %PDF-.
We also ran all three with a wrong key, which returned 401 invalid_api_key, and with an account that had 0 credits, which returned 402 insufficient_credits. Each program exited with code 1 on those errors. The 429 retry path is in the code, but our runs did not trigger it, so that part is verified by reading only.
Credits, limits and retention
A simple render costs 1 credit. A PDF adds 1 credit for each 10 pages after the first 10. A result over 5 MB adds 1 credit for each further 5 MB. These credit costs are provisional until launch.
Results are kept for 1 day on the Free plan, 7 days on Starter and Pro, and 30 days on Business. The Free plan includes 100 credits a month. There is no batch endpoint, so send one request for each document.
When to run a PDF library or a browser yourself instead
A PDF library that runs inside your own process, whether a dotnet PDF library or a PHP PDF library on your server, keeps the HTML inside your network. Running your own Chromium does the same. Neither route uses credits per document, but you install and maintain that software yourself.
The API needs no browser and no PDF library on your server, which keeps your project small. Your HTML and URLs are sent to our servers for rendering, and the PDF is kept for the retention time listed above. The render server has Liberation, Noto and Noto CJK fonts, and fonts from your own machine are not there. Choose the route that fits where your documents are allowed to go.
Questions
Do I need a NuGet package for C# HTML to PDF?
No. The C# file uses only System.Net.Http and System.Text.Json, so you can run it with dotnet run.
Can I generate a PDF from HTML in PHP without Composer?
Yes. The PHP file needs PHP 8.1 or later with the curl and json extensions, and it runs with php html_to_pdf.php.
Do I need Jackson or Gson in Java?
Not for this example. It reads the string fields it needs with a small helper, and a JSON library is the better choice for a larger project.
Do I have to install a browser?
No. The rendering runs in headless Chromium on our servers, and your code only sends HTTP requests and saves the PDF.
How do I add page numbers?
Use display_header_footer with header_template and footer_template in params. Put spans with the classes pageNumber and totalPages in the template, and leave room in the margin for them.
Can I convert many documents at once?
There is no batch endpoint. Send one request for each document, and poll each job until it finishes.
How long are the PDFs kept?
On the Free plan, results are kept 1 day. Starter and Pro keep them 7 days, and Business keeps them 30 days, so download the file within that time.
What does it cost?
A simple render costs 1 credit, and a PDF adds 1 credit for each 10 pages after the first 10. The Free plan includes 100 credits a month, and the costs are provisional until launch.
Is there an official SDK?
This guide does not use or claim one. It calls the plain HTTP API directly, so each file works without a client library.
