API docs

Hosted wkhtmltopdf for modern integrations.

PaperAPI delivers deterministic wkhtmltopdf renders with EU-only processing, retries, queueing, and observability. Use the same request for synchronous PDFs or background jobs—no separate async endpoint required.

Base URL: https://api.paperapi.de

Quickstart: stream a PDF

Send HTML + options and keep the default wait window (10s). The response streams application/pdf when the renderer finishes quickly. Add --output to save the file directly.

curl https://api.paperapi.de/v1/generate \
  -H "Authorization: Bearer pk_live_xxx" \
  -H "Accept: application/pdf" \
  --json '{
    "html": "<html><body><h1>Invoice</h1></body></html>"
  }' \
  --output invoice.pdf

Quickstart: force async + poll

Add Prefer: respond-async to turn the same endpoint into a background job. Every 202 response includes createdAt, expiresAt, legacy downloadUrl, and links.self/result, plus Location and Retry-After headers so you know when to poll.

# 1) Enqueue with respond-async
curl https://api.paperapi.de/v1/generate \
  -H "Authorization: Bearer pk_live_xxx" \
  -H "Prefer: respond-async" \
  --json '{
    "html": "<html><body><p>Background job</p></body></html>"
  }'

# 2) Poll the job (repeat until status == "Succeeded")
curl https://api.paperapi.de/jobs/{jobId} \
  -H "Authorization: Bearer pk_live_xxx"

# 3) Download the PDF
curl https://api.paperapi.de/jobs/{jobId}/result \
  -H "Authorization: Bearer pk_live_xxx" \
  --output job.pdf

Authentication & headers

All endpoints require a workspace API key or authenticated session. Keys should only live on trusted servers. Idempotency-Key is highly recommended so retries do not create duplicate jobs; PaperAPI keeps the association for 24 hours and always returns the original job envelope when the payload matches.

Required

  • Authorization

    Bearer pk_live_xxx

    Issue or revoke keys in the dashboard. Keys are scoped per workspace.

  • Content-Type

    application/json

    Use curl --json or set the header manually to avoid HTTP 415 responses.

Recommended

  • Accept

    application/pdf

    Ensures pdf responses are streamed even if an HTTP client has a default JSON accept header.

  • Idempotency-Key

    UUID / hash

    Prevents duplicate jobs for matching payloads within 24h. Repeated requests return HTTP 202 with the original job envelope instead of rendering again.

  • Prefer

    respond-async | wait=N

    Add respond-async to return HTTP 202 immediately. Set wait=N (0–30, default 10) to cap how long the API waits for a synchronous PDF before falling back to a job.

Response headers

  • Location

    Set by PaperAPI

    All 202 responses include an absolute jobStatusUrl so you can poll without storing it yourself.

  • Retry-After

    Set by PaperAPI

    Returned on 202 (default 3 seconds) and 429 responses to tell clients when to retry.

  • X-Request-Id

    Set by PaperAPI or forwarded from your proxy

    Echoed on every response and added to error bodies as requestId for debugging.

Endpoints

POST

/v1/generate

Render HTML to PDF (sync or async).
  • Streams application/pdf when wkhtmltopdf completes inside the wait window (default 10 seconds).
  • Use Prefer: respond-async to always enqueue a job, or Prefer: wait=N (0-30) to customize the synchronous wait cap.
  • Idempotency-Key is recommended. Matching requests reuse the original job for 24 hours and respond with 202 + the existing job payload.

Headers

Authorization · Content-Type · Prefer (optional) · Idempotency-Key (optional)

Request

{
  "html": "<html><body><h1>Invoice #512</h1></body></html>",
  "options": {
    "pageSize": "A4",
    "marginTop": 12,
    "printMediaType": true,
    "disableSmartShrinking": true
  }
}

Responses

  • 200 application/pdf stream. Returned when rendering finishes before Prefer: wait=N expires.
  • 202 Job envelope with createdAt, expiresAt, legacy downloadUrl, and links.self/result. Includes Location + Retry-After headers.
  • 400 Missing html or invalid option (see error details).
  • 401 Missing/invalid API key or session.
  • 413 HTML payload larger than 500 KB.
  • 429 Monthly quota or rate limit exceeded. Respect Retry-After.

Example

curl https://api.paperapi.de/v1/generate \
  -H "Authorization: Bearer pk_live_xxx" \
  -H "Prefer: wait=5" \
  --json '{
    "html": "<html><body><p>Welcome</p></body></html>",
    "options": { "pageSize": "A4" }
  }' \
  --output welcome.pdf

GET

/jobs/{jobId}

Poll job status.
  • Returns the same envelope as the 202 response (id, status, createdAt, expiresAt, jobStatusUrl, downloadUrl, links).
  • Call the Location header returned by POST /v1/generate or use the jobStatusUrl field stored in your system.

Headers

Authorization

Request

No body.

Responses

  • 200 Job envelope with optional errorMessage.
  • 401 Missing/invalid credentials.
  • 404 Job not found or does not belong to the authenticated workspace.

Example

curl https://api.paperapi.de/jobs/{jobId} \
  -H "Authorization: Bearer pk_live_xxx"

GET

/jobs/{jobId}/result

Download a completed PDF.
  • Streams the PDF for succeeded jobs and deletes the temporary file afterwards.
  • Returns 404 until the job status becomes Succeeded.

Headers

Authorization

Request

No body.

Responses

  • 200 application/pdf stream.
  • 401 Missing/invalid credentials.
  • 404 Job still running, failed, or missing output.

Example

curl https://api.paperapi.de/jobs/{jobId}/result \
  -H "Authorization: Bearer pk_live_xxx" \
  --output job.pdf

GET

/v1/usage

Retrieve the current month usage snapshot.
  • Returns used, monthlyLimit, remaining, overage, and nextRechargeAt (UTC month boundary).
  • Fields clamp at zero so you can display progress bars without extra math.

Headers

Authorization

Request

No body.

Responses

  • 200 Usage summary JSON.
  • 401 Missing/invalid credentials.

Example

curl https://api.paperapi.de/v1/usage \
  -H "Authorization: Bearer pk_live_xxx"

GET

/v1/whoami

Return the active workspace profile.
  • Useful for debugging or showing the current plan inside internal tooling.
  • Name falls back to the email prefix when no company info is set.

Headers

Authorization

Request

No body.

Responses

  • 200 Workspace id, name, email, plan metadata.
  • 401 Missing/invalid credentials.

Example

curl https://api.paperapi.de/v1/whoami \
  -H "Authorization: Bearer pk_live_xxx"

Job model

Async responses and the /jobs/{jobId} endpoint return the following JSON. New fields (createdAt, expiresAt, and links) coexist with the legacy jobStatusUrl and downloadUrl fields for backward compatibility.

{
  "id": "job-guid",
  "status": "Queued | Processing | Succeeded | Failed",
  "createdAt": "2025-04-02T12:00:00Z",
  "expiresAt": "2025-04-09T12:00:00Z",
  "errorMessage": "optional context",
  "jobStatusUrl": "/jobs/{jobId}",
  "downloadUrl": "/jobs/{jobId}/result",
  "links": {
    "self": "/jobs/{jobId}",
    "result": "/jobs/{jobId}/result"
  }
}
  • QueuedJob is waiting to be rendered (plan priority applies).
  • Processingwkhtmltopdf is actively generating the PDF.
  • SucceededDownload via downloadUrl or links.result.
  • FailedSee errorMessage. Fix payload or retry if the failure was transient.

Options

All fields mirror wkhtmltopdf flags. Numbers can be sent as JSON numbers or strings. Start with the common options below, then layer advanced tweaks as needed.

Common

pageSize

Example: A4

Maps to --page-size

A4, Letter, Legal, etc. Default A4.

orientation

Example: portrait | landscape

Maps to --orientation

Case-insensitive. Default portrait.

margins

Example: marginTop/marginRight/marginBottom/marginLeft

Maps to --margin-top, --margin-right, --margin-bottom, --margin-left

Millimeters as decimals. Configure each side independently. Default 0mm.

printMediaType

Example: true

Maps to --print-media-type

Respect @media print rules. Default false.

disableSmartShrinking

Example: true

Maps to --disable-smart-shrinking

Avoid layout shifts from wkhtmltopdf auto-scaling. Default false.

header/footer text

Example: headerLeft/headerCenter/headerRight, footerRight

Maps to --header-left, --header-center, --header-right, --footer-left, --footer-center, --footer-right

Plain text placeholders (supports [page], [fromPage], etc.). Default empty.

headerHtml / footerHtml

Example: <html><body>Brand</body></html>

Maps to --header-html, --footer-html

Inline HTML snippets rendered from temp files so you never host separate URLs.

dpi & imageQuality

Example: dpi: 150, imageQuality: 90

Maps to --dpi, --image-quality

Higher values increase fidelity and file size. Defaults: dpi 96, imageQuality 94.

zoom

Example: 1.1

Maps to --zoom

Scale the output. >0, default 1.0.

images / noImages

Example: images: true | noImages: true

Maps to --images, --no-images

Force-enable or disable images explicitly. Leave both unset for wkhtmltopdf defaults.

enableJavascript / disableJavascript

Example: enableJavascript: true

Maps to --enable-javascript, --disable-javascript

JavaScript is disabled by default for deterministic renders. Toggle explicitly when needed.

Advanced

headerSpacing / footerSpacing

Example: headerSpacing: 5

Maps to --header-spacing, --footer-spacing

Spacing in millimeters between the header/footer and body. Default 0.

imageDpi

Example: 300

Maps to --image-dpi

Control raster image DPI independently from page DPI.

lowQuality

Example: true

Maps to --lowquality

Shortcut flag for smaller, faster renders. Default false.

header/footer placeholders

Example: [page] / [date]

Maps to --header-left, --footer-right

wkhtmltopdf supports [page], [fromPage], [toPage], [date], etc. Use text placeholders or HTML snippets.

Recommended defaults (invoice)

{
  "options": {
    "pageSize": "A4",
    "orientation": "portrait",
    "marginTop": 12,
    "marginBottom": 12,
    "printMediaType": true,
    "disableSmartShrinking": true,
    "footerRight": "Page [page] of [toPage]",
    "dpi": 150,
    "imageQuality": 90
  }
}

Client snippets

Handle both streaming and async responses by checking the status code. Below are minimal Node.js (fetch) and .NET HttpClient examples.

Node.js (fetch)

import fs from "node:fs/promises";
import fetch from "node-fetch";

const response = await fetch("https://api.paperapi.de/v1/generate", {
  method: "POST",
  headers: {
    Authorization: "Bearer pk_live_xxx",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ html: "<html><body>Hello</body></html>" }),
});

if (response.status === 202) {
  const job = await response.json();
  const status = await fetch(`https://api.paperapi.de${job.links.self}`, {
    headers: { Authorization: "Bearer pk_live_xxx" },
  });
  console.log("Job status", await status.json());
} else if (response.ok) {
  const pdf = Buffer.from(await response.arrayBuffer());
  await fs.writeFile("invoice.pdf", pdf);
} else {
  const error = await response.json();
  console.error("PaperAPI error", error.requestId, error.details);
}

.NET HttpClient

using System.Net;
using System.Net.Http.Json;
using System.Net.Http.Headers;

using var client = new HttpClient { BaseAddress = new Uri("https://api.paperapi.de") };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "pk_live_xxx");

var payload = new { html = "<html><body>Hi</body></html>" };
using var response = await client.PostAsJsonAsync("/v1/generate", payload);

if (response.StatusCode == HttpStatusCode.Accepted)
{
    var job = await response.Content.ReadFromJsonAsync<JobResponse>();
    Console.WriteLine($"Job {job?.Id} queued at {job?.CreatedAt}");
}
else if (response.IsSuccessStatusCode)
{
    var bytes = await response.Content.ReadAsByteArrayAsync();
    await File.WriteAllBytesAsync("invoice.pdf", bytes);
}
else
{
    var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
    Console.WriteLine($"PaperAPI error {error?.RequestId}: {error?.Details}");
}

record JobLinks(string Self, string? Result);
record JobResponse(Guid Id, string Status, DateTimeOffset CreatedAt, JobLinks Links);
record ErrorResponse(string Error, string Details, string? RequestId);

Errors

Every error response follows the envelope below. The optional requestId matches the X-Request-Id header, making it easy to forward logs to support.

{
  "error": "invalid_payload",
  "details": "The 'html' field is required.",
  "requestId": "req_3b0c4f2f9a"
}
  • 400Validation failed (missing html or invalid options).
  • 401Missing or invalid API key.
  • 413HTML payload larger than 500 KB.
  • 429Rate limit or plan quota exceeded. Retry with backoff or upgrade capacity.
  • 500Renderer failed to produce a PDF (check errorMessage or requestId when contacting support).

Failure scenarios (curl)

# 401 - invalid key
curl -i https://api.paperapi.de/v1/generate --json '{ "html": "<p>Missing key</p>" }'

# 413 - payload too large
curl -i https://api.paperapi.de/v1/generate \
  -H "Authorization: Bearer pk_live_xxx" \
  --data-binary @huge.html

# 429 - respect Retry-After and Idempotency-Key
curl -i https://api.paperapi.de/v1/generate \
  -H "Authorization: Bearer pk_live_xxx" \
  -H "Prefer: respond-async" \
  -H "Idempotency-Key: 2d6f7b7d-67af-4d6b-a5d2-123456789abc" \
  --json '{ "html": "<p>Heavy load</p>" }'

Rate limits & retries

Rate limits depend on your plan. Each response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix epoch seconds). Respect Retry-After on HTTP 202 and 429 responses, and apply exponential backoff before retrying idempotent requests.

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1714675200
Retry-After: 3

If you frequently hit the limit, reuse Idempotency-Key for automatic dedupe, shorten Prefer: wait=N, and reach out for dedicated capacity.