Turn HTML into a PDF the way a browser prints it, because a browser prints it.
POST /v1/pdf lays your HTML out in headless Chromium and returns the printed PDF. Not a PDF-drawing library approximating a page — the actual layout engine, so line breaking, the cascade, and bidi text all work the way they do in a browser window. That last part is why this endpoint exists: Persian and Arabic script is cursive and right-to-left with embedded Latin runs, and reproducing that correctly outside a real browser is a project, not a setting.
/v1/pdfNo network
--host-resolver-rules=MAP * 0.0.0.0, so a file:// path, an internal address, and a public URL all fail identically. This is deliberate: the renderer is an attacker's easiest way to make your server fetch something it should not (a cloud metadata endpoint, a service that is only reachable from inside the network). Any src=, href=, content= or CSS url() that starts with http:, https:, //, file: or ftp: is refused with 400 before rendering starts. Images and fonts go in as data: URIs; styles go in a <style> block in the document itself, never a linked stylesheet.Parameters
| Parameter | Type | Description |
|---|---|---|
| html Required | string | The document body, up to 4,000,000 characters. If it does not already contain <html, it is wrapped for you in a minimal page with the base stylesheet shown below. |
| filename Optional | string | Sets Content-Disposition. Only the base name is kept.Default: document.pdf |
| direction Optional | string | auto, ltr or rtl. See Right-to-left below — pass it explicitly for anything that matters.Default: auto |
| format Optional | string | Page size: A4, Letter, Legal, and the other sizes Chromium's print engine accepts.Default: A4 |
| margin Optional | string | Space-separated CSS lengths. One value sets every side; two set block then inline, the way the default does. Give one or two — this is not a full four-value shorthand. Default: 20mm 18mm |
| header Optional | string | An HTML template printed at the top of every page. Setting either header or footer turns both on; see Headers and footers below. |
| footer Optional | string | An HTML template printed at the bottom of every page. Defaults to a page x / y counter when header is set but footer is not. |
| landscape Optional | boolean | Rotate the page. Default: false |
Right-to-left
direction: "auto" counts Persian-script characters in the body and switches to rtl once they pass 15% of it — good enough for a document that is entirely one script or the other, wrong for the common case of a short Persian cover note quoting a long English contract. Pass direction explicitly whenever the outcome matters. Vazirmatn, Noto Naskh Arabic, Noto Sans and DejaVu Sans Mono are installed on the renderer and used automatically; anything else — a house typeface, a logo font — has to be embedded the same way an image does, as a data: URI in an @font-face rule, because the renderer cannot fetch it.
Headers and footers
header and footer are Chromium's own print templates: small HTML fragments that may use the classes date, title, url, pageNumber and totalPages, which Chromium fills in itself. Keep the font small — the template renders inside the page margin, so a tall header needs a taller margin to avoid overlapping the body.
<div style="width:100%;font-size:8px;text-align:center;color:#8b93a3;padding:0 12mm">
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>A complete template
Everything this document references — the logo, the custom font — is inlined. There is nothing outside the request for the renderer to fail to reach.
<!doctype html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8">
<style>
@font-face {
font-family: "Brand Sans";
src: url(data:font/woff2;base64,d09GMgABAAAAAAaw...) format("woff2");
}
body { font-family: "Brand Sans", "Noto Sans", sans-serif; }
.letterhead { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.letterhead img { width: 40px; height: 40px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #d3d8e0; padding: 6px 10px; }
</style>
</head>
<body>
<div class="letterhead">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAg...">
<h1>Invoice #1042</h1>
</div>
<table>
<thead><tr><th>Item</th><th>Amount</th></tr></thead>
<tbody><tr><td>Legal research, 3.5h</td><td>$210.00</td></tr></tbody>
</table>
</body>
</html>Calling it
curl https://api.console.larsa.larsima.com/v1/pdf \
-H "Authorization: Bearer $LARSA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Hello</h1><p>Rendered by a real browser.</p>",
"filename": "hello.pdf",
"direction": "ltr"
}' \
--output hello.pdfimport os
import requests
resp = requests.post(
"https://api.console.larsa.larsima.com/v1/pdf",
headers={"Authorization": f"Bearer {os.environ['LARSA_API_KEY']}"},
json={
"html": "<h1>Hello</h1><p>Rendered by a real browser.</p>",
"filename": "hello.pdf",
"direction": "ltr",
},
)
resp.raise_for_status()
with open("hello.pdf", "wb") as f:
f.write(resp.content)import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.console.larsa.larsima.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LARSA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: "<h1>Hello</h1><p>Rendered by a real browser.</p>",
filename: "hello.pdf",
direction: "ltr",
}),
});
if (!res.ok) throw new Error(await res.text());
await writeFile("hello.pdf", Buffer.from(await res.arrayBuffer()));import { writeFile } from "node:fs/promises";
interface PdfRequest {
html: string;
filename?: string;
direction?: "auto" | "ltr" | "rtl";
}
const body: PdfRequest = {
html: "<h1>Hello</h1><p>Rendered by a real browser.</p>",
filename: "hello.pdf",
direction: "ltr",
};
const res = await fetch("https://api.console.larsa.larsima.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LARSA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
await writeFile("hello.pdf", Buffer.from(await res.arrayBuffer()));package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]string{
"html": "<h1>Hello</h1><p>Rendered by a real browser.</p>",
"filename": "hello.pdf",
"direction": "ltr",
})
req, _ := http.NewRequest("POST", "https://api.console.larsa.larsima.com/v1/pdf",
bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("LARSA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := os.Create("hello.pdf")
defer out.Close()
io.Copy(out, resp.Body)
}use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = std::env::var("LARSA_API_KEY")?;
let client = reqwest::blocking::Client::new();
let resp = client
.post("https://api.console.larsa.larsima.com/v1/pdf")
.bearer_auth(key)
.json(&serde_json::json!({
"html": "<h1>Hello</h1><p>Rendered by a real browser.</p>",
"filename": "hello.pdf",
"direction": "ltr"
}))
.send()?
.error_for_status()?;
fs::write("hello.pdf", resp.bytes()?)?;
Ok(())
}import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
public class Pdf {
public static void main(String[] args) throws Exception {
String key = System.getenv("LARSA_API_KEY");
String body = "{\"html\":\"<h1>Hello</h1><p>Rendered by a real browser.</p>\",\"filename\":\"hello.pdf\",\"direction\":\"ltr\"}";
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.console.larsa.larsima.com/v1/pdf"))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<byte[]> resp = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
Files.write(Path.of("hello.pdf"), resp.body());
}
}using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var key = Environment.GetEnvironmentVariable("LARSA_API_KEY");
var payload = JsonSerializer.Serialize(new {
html = "<h1>Hello</h1><p>Rendered by a real browser.</p>",
filename = "hello.pdf",
direction = "ltr",
});
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var resp = await client.PostAsync(
"https://api.console.larsa.larsima.com/v1/pdf",
new StringContent(payload, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync("hello.pdf", await resp.Content.ReadAsByteArrayAsync());<?php
$ch = curl_init("https://api.console.larsa.larsima.com/v1/pdf");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("LARSA_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"html" => "<h1>Hello</h1><p>Rendered by a real browser.</p>",
"filename" => "hello.pdf",
"direction" => "ltr",
]),
CURLOPT_RETURNTRANSFER => true,
]);
$pdf = curl_exec($ch);
curl_close($ch);
file_put_contents("hello.pdf", $pdf);require "net/http"
require "json"
require "uri"
uri = URI("https://api.console.larsa.larsima.com/v1/pdf")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
"Authorization" => "Bearer #{ENV['LARSA_API_KEY']}")
req.body = {
html: "<h1>Hello</h1><p>Rendered by a real browser.</p>",
filename: "hello.pdf",
direction: "ltr",
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("hello.pdf", res.body)The response
| Header | Meaning |
|---|---|
Content-Type | application/pdf. |
Content-Disposition | attachment; filename="…", from your filename. |
X-Lardad-Elapsed | Render time in seconds. |
X-Lardad-Bytes | Size of the PDF in bytes — check this before trusting a short response. |
{"detail": "…"}, not the {"error": {…}} shape the chat and audio endpoints use. See Errors.