Build documents
into your application.
A practical guide to generating PDFs, combining pages, and connecting the API to your own platform.
Try it before you integrate.
The template studio and merge workspace use the same API endpoints as your application.
/api/v1/templatesDiscover preset templates and request examples (X-Api-Key required)/api/v1/pdfs/{id}Populate a preset template/api/v1/pdfs/custom-templateSupply a template definition and data/api/v1/pdfs/mergeCombine supplied PDF pages/api/v1/pdfs/page-numbersNumber an existing PDFSend Content-Type: application/json. A successful POST returns 200 OK, Content-Type: application/pdf, and a download filename in Content-Disposition. Save the response as bytes. Error responses are JSON.
Your first PDF
This example creates a complete document with caller supplied wording. Choose your preferred client. These examples use the address of the API you are viewing; change it to your deployment when integrating.
curl --fail-with-body \
-X POST __API_ORIGIN__/api/v1/pdfs/custom-template \
-H 'Content-Type: application/json' \
--data '{"template":{"title":"{{data.title}}","pages":[{"layout":"flow","blocks":[{"type":"heading","text":"{{labels.heading}}"},{"type":"paragraph","text":"{{data.summary}}"}]}]},"labels":{"heading":"Overview"},"data":{"title":"Project brief","summary":"Made for your app."}}' \
--output project-brief.pdf$baseUrl = '__API_ORIGIN__'
$requestPath = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
$json = @{
template = @{ title = '{{data.title}}'; pages = @(@{ layout = 'flow'; blocks = @(
@{ type = 'heading'; text = '{{labels.heading}}' }
@{ type = 'paragraph'; text = '{{data.summary}}' }
) }) }
labels = @{ heading = 'Overview' }
data = @{ title = 'Project brief'; summary = 'Made for your app.' }
} | ConvertTo-Json -Depth 10
[IO.File]::WriteAllText($requestPath, $json, [Text.UTF8Encoding]::new($false))
try {
curl.exe --fail-with-body -X POST "$baseUrl/api/v1/pdfs/custom-template" `
-H 'Content-Type: application/json' `
--data-binary "@$requestPath" --output '.\project-brief.pdf'
if ($LASTEXITCODE -ne 0) { throw 'PDF generation failed.' }
} finally { Remove-Item -LiteralPath $requestPath }using System.Net.Http.Json;
using var client = new HttpClient { BaseAddress = new Uri("__API_ORIGIN__") };
// If required: client.DefaultRequestHeaders.Add("X-Api-Key", yourKey);
using var response = await client.PostAsJsonAsync("/api/v1/pdfs/custom-template", new
{
template = new
{
title = "{{data.title}}",
pages = new[] { new { layout = "flow", blocks = new[] {
new { type = "heading", text = "{{labels.heading}}" },
new { type = "paragraph", text = "{{data.summary}}" }
} } }
},
labels = new { heading = "Overview" },
data = new { title = "Project brief", summary = "Made for your app." }
});
response.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync("project-brief.pdf", await response.Content.ReadAsByteArrayAsync());import { writeFile } from 'node:fs/promises';
const response = await fetch('__API_ORIGIN__/api/v1/pdfs/custom-template', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // Add X-Api-Key if required.
body: JSON.stringify({
template: {
title: '{{data.title}}',
pages: [{ layout: 'flow', blocks: [
{ type: 'heading', text: '{{labels.heading}}' },
{ type: 'paragraph', text: '{{data.summary}}' }
] }]
},
labels: { heading: 'Overview' },
data: { title: 'Project brief', summary: 'Made for your app.' }
})
});
if (!response.ok) throw new Error(await response.text());
await writeFile('project-brief.pdf', Buffer.from(await response.arrayBuffer()));On deployments that require a key, add -H 'X-Api-Key: YOUR_KEY' to cURL. Use a backend for calls from your platform so its API key stays private.
Templates. Your wording.
A preset template is a layout registered by this deployment. Its catalog entry includes a complete, editable request example; in the API response the existing example property means a ready-to-submit request body, not a separate template definition. The production deployment exposes its production catalog. The downloadable application defaults to the smaller generic catalog. Preset catalog reads, detail pages, and request-example downloads require a valid X-Api-Key. Browse the preset references or use GET /api/v1/templates/{id} with the key in a request header.
Each PDF request can provide labels and data maps. Text fields in a layout can refer to them as {{labels.heading}} and {{data.name}}. You provide the printable labels and values on each call. Every referenced key must be present.
{
"template": {
"title": "{{data.title}}",
"pages": [{ "layout": "flow", "blocks": [
{ "type": "heading", "text": "{{labels.heading}}" },
{ "type": "paragraph", "text": "{{data.summary}}" }
] }]
},
"labels": { "heading": "Project overview" },
"data": { "title": "October update", "summary": "Prepared by Alex Example." }
}Labels must be strings. Data may be strings, numbers, or booleans. Every referenced key is required. Legacy preset templates also accept a compact body containing only labels, data, and optional options; the selected preset supplies the layout, and your maps replace its sample values. For other request types, send the full request example. Images and fonts are supplied as base64; use each catalog entry and the OpenAPI schema for supported fields.
Bring your own template JSON.
Use POST /api/v1/pdfs/custom-template when the layout is not in this deployment's preset catalog. Put the complete supported layout under template and send printable wording and populated values in separate labels and data maps. This route uses the shared PDF renderer without registering or changing a preset.
{
"template": {
"title": "{{data.documentTitle}}",
"pages": [{
"layout": "flow",
"blocks": [
{ "type": "heading", "text": "{{labels.heading}}" },
{ "type": "paragraph", "text": "{{data.summary}}" },
{ "type": "field", "label": "{{labels.owner}}", "value": "{{data.owner}}" }
]
}]
},
"labels": { "heading": "Project overview", "owner": "Prepared by:" },
"data": {
"documentTitle": "Project overview",
"summary": "A caller-defined layout populated by this request.",
"owner": "Alex Example"
}
}curl --fail-with-body \
-X POST '__API_ORIGIN__/api/v1/pdfs/custom-template' \
-H 'Content-Type: application/json' \
--data-binary '@custom-template.json' \
--output custom-document.pdf
# Add -H 'X-Api-Key: YOUR_KEY' when this deployment requires authentication.The template object follows the LegacyPdfRequest JSON schema: a title, one to 25 pages, supported page layouts, and optional rendering options or fonts. Keep labels and data outside that object. Unknown properties, invalid JSON types, unsupported layouts, missing bindings, and failed field validation return HTTP 400 with a JSON validation response. A successful request returns the PDF bytes. Use the preset route when you want a server-provided layout; use this route when you want to supply the layout too.
Merge supplied PDF pages
Send your PDF files to POST /api/v1/pdfs/merge. The API uses iTextSharp PdfCopy to copy PDF pages into one document. Files are processed in array order; page selections are processed in the order you provide.
{
"documents": [
{ "pdfBase64": "BASE64_OF_FIRST_PDF", "pages": [3, 1, 2] },
{ "pdfBase64": "BASE64_OF_SECOND_PDF", "pages": [1] }
],
"fileName": "combined.pdf",
"pageNumbers": { "format": "{page} / {total}" }
}| Field | Type | Behaviour |
|---|---|---|
documents | array | 1–25 PDF objects, in output order. |
pdfBase64 | string | Base64 encoded PDF bytes, without a data URL prefix. At most 10 MB decoded per file. |
pages | integer array | One-based page numbers. Omit or use null for all pages. An empty list is invalid. Repeats are allowed, e.g. [2, 1, 2]. |
fileName | string, optional | Download filename, up to 80 characters; sanitized by the API. |
pageNumbers | object, optional | Stamp your supplied format on the merged result. |
To provide individual pages, supply one-page PDFs, or select pages from larger PDFs. The result may contain up to 500 pages. The 20 MiB request limit includes base64 and JSON overhead. Uploaded files must be valid, unencrypted PDFs; active content and attachments are rejected.
$baseUrl = '__API_ORIGIN__'
$first = [IO.Path]::GetFullPath('.\first.pdf')
$second = [IO.Path]::GetFullPath('.\second.pdf')
$request = @{
documents = @(
@{ pdfBase64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($first)); pages = @(1) }
@{ pdfBase64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($second)) }
)
fileName = 'combined.pdf'
} | ConvertTo-Json -Depth 10
$requestPath = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
[IO.File]::WriteAllText($requestPath, $request, [Text.UTF8Encoding]::new($false))
try {
curl.exe --fail-with-body -X POST "$baseUrl/api/v1/pdfs/merge" `
-H 'Content-Type: application/json' `
--data-binary "@$requestPath" --output '.\combined.pdf'
if ($LASTEXITCODE -ne 0) { throw 'PDF merge failed.' }
} finally { Remove-Item -LiteralPath $requestPath }Want a merge demo?
The hosted source archive includes examples/merge-pages.ps1 and its mock request files. The generic application download contains the five template request examples only.
Existing clients may keep using documentsBase64: ["BASE64_PDF_1", "BASE64_PDF_2"] to include every page. Send exactly one of documents or documentsBase64.
Page sizes, rotation, and content are copied without rasterizing. This is a page assembly operation: document-level features such as bookmarks, digital signatures, and interactive form behaviour are not guaranteed to carry over.
Open merge workspace ↗Number an existing PDF
Use POST /api/v1/pdfs/page-numbers for an existing file, or attach the same pageNumbers object to a merge request. You provide the complete printed format.
{
"pdfBase64": "BASE64_OF_YOUR_PDF",
"fileName": "numbered.pdf",
"pageNumbers": {
"format": "Page {page} of {total}",
"fontSize": 8,
"rightMargin": 50,
"y": 50
}
}{page} is the one-based output page, and {total} is the total output count. Font size, right margin, and vertical position use PDF points (72 points = 1 inch). y is measured from the bottom. Default values are 8, 50, and 50. The position must be inside every page.
Authentication & browser calls
Check GET /api/v1/security for this deployment's key requirement, body limit, output limit, and rendering timeout. Preset catalog reads, preset detail pages, and preset example downloads always require an X-Api-Key header. PDF POST endpoints require that header when the host enables authentication. The Studio validates the same header before loading templates. Never put keys in a URL.
curl --fail-with-body '__API_ORIGIN__/api/v1/pdfs/custom-template' \
-H 'X-Api-Key: YOUR_KEY' \
-H 'Content-Type: application/json' \
--data '{"template":{"title":"Example","pages":[{"layout":"flow","blocks":[{"type":"paragraph","text":"Your content."}]}]},"labels":{},"data":{}}' \
--output example.pdfDevelopment and production settings require an API key for PDF calls. Studio always requires a valid configured key, even when anonymous generation is enabled. General guides and the deployed source download remain accessible; preset catalog and documentation details require a valid X-Api-Key. Browser workspaces hold a key only in page memory, do not save it to storage, and clear it when leaving the page.
CORS allows all origins by default, without cookies. Hosts can configure an allowlist or disable it. See CSP, CORS & response headers for configuration. Server-to-server HTTP calls do not need CORS.
CSP, CORS & response headers
These settings belong to the host. A PDF request cannot change them. Configure them in appsettings.json, environment variables, or your deployment's configuration provider, then restart the API.
CORS: all origins by default
Cors:Mode defaults to All. Browser clients receive Access-Control-Allow-Origin: *. Requests may use GET or POST and the Content-Type and X-Api-Key headers. Browsers can read Content-Disposition, Retry-After, and Link. Preflight OPTIONS requests do not require an API key.
| Mode | Behaviour |
|---|---|
All · default | Allow any browser origin, including the opaque null origin. No cookies or credentialed CORS. |
AllowList | Allow only the exact HTTP(S) origins in AllowedOrigins. Disallowed origins on PDF POSTs receive 403. |
Disabled | No cross-origin response headers. PDF POSTs accept the API's own origin and server requests without an Origin header. |
{ "Cors": { "Mode": "All", "AllowedOrigins": [] } }{
"Cors": {
"Mode": "AllowList",
"AllowedOrigins": ["https://app.example.com", "https://admin.example.com"]
}
}$env:Cors__Mode = 'AllowList'
$env:Cors__AllowedOrigins__0 = 'https://app.example.com'
# To disable CORS instead: $env:Cors__Mode = 'Disabled'Origins have no trailing slash or path; wildcards are not accepted in an allowlist. Set the mode explicitly when migrating a previous AllowedOrigins configuration. Malformed or multiple Origin values are rejected on PDF POSTs. CORS controls browser access; API keys, HTTPS, validation, and traffic limits still apply. Server-to-server HTTP calls do not need CORS.
Use credentials: 'omit' for cross-origin browser fetches. The API does not authenticate with cookies and never sends Access-Control-Allow-Credentials. Keep a shared deployment key on your application's backend; a key sent by browser JavaScript is visible to that browser's user.
curl -i -X OPTIONS '__API_ORIGIN__/api/v1/pdfs/custom-template' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type,X-Api-Key'Content Security Policy
The frontend enforces a strict policy by default. Scripts, styles, fonts, connections, and workers are served from this deployment. Inline scripts and evaluation are blocked. Images may also use data/blob URLs for PDF tools; framing, plugins, and base URL changes are prohibited.
default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'To customize the policy, set SecurityHeaders:ContentSecurityPolicy to the complete policy string. To inspect violations without blocking resources, set SecurityHeaders:ContentSecurityPolicyReportOnly to true. This switches to the report-only header and disables CSP enforcement; keep the default false for enforcement. Review violations in browser developer tools. No report collection endpoint is included; configure your own collector and policy reporting directives if needed.
$env:SecurityHeaders__ContentSecurityPolicyReportOnly = 'true'
# Return to enforcement after reviewing browser violations:
$env:SecurityHeaders__ContentSecurityPolicyReportOnly = 'false'Suppress server and framework headers
Kestrel's Server header is disabled. Application responses remove Server, X-Powered-By, X-AspNet-Version, and X-AspNetMvc-Version, including validation and authentication errors. The supplied web.config also suppresses IIS server and framework headers on supported IIS 10 installations.
A reverse proxy, gateway, or HTTP.sys error response may add its own headers after the application runs. Configure that layer too, and inspect the public URL, including error responses. Header suppression reduces HTTP fingerprinting; it does not replace runtime updates. The source download and OpenAPI remain public, and iText's required PDF producer metadata is preserved.
curl -I '__API_ORIGIN__/docs'| Additional header | Default |
|---|---|
| X-Content-Type-Options | nosniff |
| X-Frame-Options | DENY |
| Referrer-Policy | no-referrer |
| Permissions-Policy | Camera, microphone, location, payment, and USB disabled |
| Cross-Origin-Opener-Policy / Resource-Policy | same-origin; API calls use CORS |
| Cache-Control | no-store |
| Strict-Transport-Security | 180 days on applicable HTTPS responses when HTTPS is required |
References: Microsoft CORS guidance, OWASP CSP guidance, and IIS header filtering.
OWASP guidance in practice
The application includes controls aligned with the OWASP API Security Top 10 (2023) and REST Security Cheat Sheet. The source download includes a detailed implementation mapping in docs/owasp.md.
| Area | Implemented controls |
|---|---|
| Authentication & access | Key validation before Studio tools load, API keys on protected PDF POST routes, constant-time digest comparison, required-key startup checks, and HTTPS enforcement by default. |
| Resource consumption | Request/output budgets, page and asset limits, IP rate limits, bounded concurrency/queue, and cooperative rendering deadlines. |
| SSRF & unsafe content | Request-owned image/font/PDF bytes; XHTML external resources and entities prohibited; imported PDF scripts, attachments, remote actions, and external streams rejected. |
| Frontend & responses | Strict CSP, safe DOM text insertion, framing protection, no browser key storage, bounded PDF previews, safe filenames, and sanitized error responses. |
| Configuration & inventory | Configurable CORS (all origins by default, without cookies), host restrictions, explicit proxy trust, suppressed server/framework headers, OpenAPI, versioned routes, and corresponding source downloads. |
| Dependencies & secrets | NuGet vulnerability auditing, build failure for known vulnerability warnings, source-file allowlists, and credential removal from bundled configuration. |
Understand the current boundaries
Every configured key grants the same PDF permissions. Key issuance, expiry, rotation, revocation, and per-client quotas are not automated. Current traffic quotas apply per IP and per application process. The service generates a result for each call and has no stored customer documents or document retrieval endpoints.
File validation is not antivirus or a hard sandbox. PDF, image, and font decoders run inside the API process, and cancellation is cooperative. Operators should use isolated rendering with hard resource limits, gateway protection, and monitored security events for public workloads. Keep the runtime, proxy, operating system, and vendored browser libraries patched.
OWASP advises stronger authentication for sensitive or high-value resources. Configure appropriate identities and permissions for those deployments. Framework logging is present; a dedicated audit trail and alerting system still need to be configured or integrated.
Verification status
Security regression tests and browser checks exercise the implemented controls. An independent penetration test and a full OWASP ASVS assessment have not been completed.
Limits & useful errors
| Constraint | Limit |
|---|---|
| JSON request / generated response | 20 MiB / 32 MiB |
| Imported PDF / merged output | 10 MB per PDF / 500 pages total |
| PDFs in one merge call | 25 |
| Default generation rate | 30 requests per minute per IP |
| Default concurrent generations | 2, with a queue of 8 |
| Default rendering deadline | 60 seconds; cooperative cancellation |
| Status | Action |
|---|---|
400 Invalid request | Read the JSON errors map and correct the indicated fields. Use the HTTPS URL for HTTPS required errors. |
401 Unauthorized | Supply the deployment's API key. |
403 Forbidden | Check the allowed origins for this deployment. |
404 Unknown template | Retrieve a valid ID from the template catalog. |
408 Deadline exceeded | Reduce the workload; rendering cancellation is cooperative. |
413 / 415 | Reduce request size / send JSON with the correct content type. |
429 Too many requests | Respect Retry-After when provided. |
500 Server error | Use the trace identifier to investigate server logs. |
{
"title": "Invalid PDF operation",
"status": 400,
"errors": { "documents[0].pages[0]": ["Use a page number between 1 and 3."] }
}Run the API yourself
Download the application source, extract it, install the .NET 10 SDK, and open a terminal in the extracted folder containing PdfGenerator.slnx.
dotnet restore PdfGenerator.slnx
$apiKey = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
dotnet user-secrets set 'Security:ApiKeys:0' $apiKey --project src/PdfGenerator.Api
Set-Clipboard -Value $apiKey
dotnet run --project src/PdfGenerator.Api --urls http://localhost:5000Open http://localhost:5000 and paste the copied key into Studio. The repository launch profile uses port 5149 if you omit --urls. For production, follow docs/security.md in the source download: configure separate API keys, allowed hosts, HTTPS, proxy trust, and workload limits. Keep credentials in environment variables or a secret store.
Source for this running API
The application is distributed under GNU AGPL-3.0 with its license and third party notices. Download the source bundled with this deployment. The download centre includes its SHA-256 checksum and build instructions.
Review LICENSE, COPYRIGHT, and THIRD_PARTY_NOTICES.md when using or redistributing the application. This software comes with no warranty.