Project

poli-page

0.0
The project is in a healthy, maintained state
Official Ruby client for Poli Page. Renders PDFs, previews, and thumbnails via api.poli.page.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0.1
 Project Readme

Poli Page SDK for Ruby

Gem Downloads Ci Codeql Coverage Ruby Types Style Deps Docs License

Official Ruby SDK for Poli Page — render polished PDFs from HTML templates via the Poli Page API.

Documentation: https://poli-page.github.io/sdk-ruby/

Install

# Gemfile
gem "poli-page"
bundle install
# or
gem install poli-page

Requires Ruby >= 3.2.

The gem has zero runtime dependencies — only the stdlib (net/http, json, uri, securerandom, logger, openssl).

Quick start

Project mode — render a published template by slug

require "poli_page"

client = PoliPage::Client.new(api_key: ENV.fetch("POLI_PAGE_API_KEY"))

pdf = client.render.pdf(
  project:  "getting-started",
  template: "welcome",
  version:  "1.0.0",
  data:     { name: "World" }
)
# pdf is a binary-encoded String

Every Poli Page org comes pre-provisioned with a getting-started/welcome template, so the snippet above runs as-is the moment you have an API key — no project setup needed. For your own templates, swap the slugs once you've pushed a version with the poli CLI:

pdf = client.render.pdf(
  project:  "billing",
  template: "invoice",
  version:  "1.0.0",
  data:     { invoice_number: "INV-001", total: 1280 }
)

Preview inline HTML

render.preview accepts raw HTML for live editing and visual inspection without producing a stored document. Use this for editor previews or layout tests.

result = client.render.preview(
  template: "<h1>Hello {{ name }}</h1>",
  data:     { name: "World" }
)
puts "Rendered #{result.total_pages} page(s) in #{result.environment} mode"

render.pdf, render.pdf_stream, and render.document require project modeproject + template, optionally pinned to a specific version (omit to render the current draft). Inline HTML is only accepted by render.preview.

Write a PDF to disk

client.render_to_file(
  "./welcome.pdf",
  project: "getting-started", template: "welcome", version: "1.0.0",
  data:    { name: "World" }
)

render_to_file streams the response bytes directly to disk via render.pdf_stream — bounded memory regardless of PDF size. Parent directories are created on the fly.

Try it locally — runnable demo

ruby examples/demo.rb

The demo walks every public method end-to-end and writes the results to examples/output/. First run prompts for a pp_test_* key and saves it to .env at the project root. Subsequent runs are silent. The POLI_PAGE_API_KEY env var wins over the file when set.

Stream — for large PDFs or piping to S3 / HTTP responses

File.open("invoice.pdf", "wb") do |io|
  client.render.pdf_stream(
    project: "billing", template: "invoice", version: "1.0.0",
    data:    { invoice_number: "INV-001" }
  ) do |chunk|
    io.write(chunk)
  end
end

Without a block, pdf_stream returns an Enumerator that composes with Enumerable:

enum = client.render.pdf_stream(project: "billing", template: "invoice", version: "1.0.0", data: data)
enum.each { |chunk| s3.upload_part(part_number: n += 1, body: chunk) }

Working with stored documents

Every render produces a stored document, accessible via document_id for later download or thumbnails. render.pdf and render.pdf_stream are conveniences that chain a presigned-URL fetch internally to return bytes; render.document returns just the descriptor (skip the auto-download when you'll fetch the bytes later).

# 1. Render and store
doc = client.render.document(
  project:  "billing",
  template: "invoice",
  version:  "1.0.0",
  data:     { invoice_number: "INV-001" },
  metadata: { customer_id: "cust_123" }   # your own audit data
)
# doc.document_id, doc.page_count, doc.size_bytes, doc.presigned_pdf_url, doc.metadata, ...

# 2. Persist doc.document_id in your database
db.invoices.update(id: "INV-001", document_id: doc.document_id)

# 3. Later, fetch a fresh presigned URL + download
fresh = client.documents.get(doc.document_id)
pdf   = fresh.download_pdf

# 4. Generate thumbnails (Starter+ tier)
thumbs = client.documents.thumbnails(doc.document_id, width: 320, format: "png")

# 5. When done, soft-delete
client.documents.delete(doc.document_id)

The presigned URL has a 15-minute TTL. If download_pdf raises PoliPage::DownloadError with status: 403 from S3, call documents.get(id) to refresh and retry.

Authentication & environments

The mode is determined by the API key prefix:

  • pp_test_… → sandbox mode (not billed, generous rate limits)
  • pp_live_… → live mode (billed, production rate limits)
  • pp_sa_… → service-account keys; environment matches the SA's configuration (sandbox or live)

All prefixes hit the same endpoint (https://api.poli.page). The SDK passes the key through as a Bearer token and never inspects the prefix — pick whichever fits your deploy model.

Methods

Method Returns Description
client.render.pdf(**input) String (binary bytes) Render a PDF, return bytes
client.render.pdf_stream(**input) { |c| … } Enumerator or yields chunks Render and stream the response
client.render.preview(**input) PoliPage::PreviewResult Paginated HTML preview
client.render.document(**input) PoliPage::DocumentDescriptor Render and return descriptor (skip auto-download)
client.documents.get(id) PoliPage::DocumentDescriptor Retrieve a stored document
client.documents.preview(id) PoliPage::DocumentPreviewResult Stored document's paginated HTML
client.documents.thumbnails(id, **opts) Array<PoliPage::Thumbnail> Page thumbnails (PNG/JPEG, base64)
client.documents.delete(id) nil Soft-delete a stored document
client.render_to_file(path, **input) nil Render and stream to disk

Configuration

Option Type Default Description
api_key: String (required) pp_test_* or pp_live_* API key
base_url: String "https://api.poli.page" API base URL
max_retries: Integer 2 Max retry attempts on retryable errors
retry_delay: Numeric (seconds) 0.5 Base delay before the first retry
timeout: Numeric (seconds) 60 Per-request timeout
logger: Logger (any duck type) nil Hook errors and DEBUG events here
on_retry: #call(PoliPage::RetryEvent) nil Called when a retry is scheduled
on_error: #call(PoliPage::Error) nil Called when a call terminates in error
proxy: String (URL) nil (uses env) Override HTTP proxy; e.g. "http://u:p@host:8080"
ca_file: String (path) nil Custom CA bundle (corp MITM, private PKI)
ca_path: String (path) nil Custom CA directory (hashed cert dir)

http_proxy, https_proxy, and no_proxy environment variables are honored automatically by Net::HTTP's :ENV proxy resolution — no configuration needed in the common case. Pass proxy: to override. For TLS verification against a private CA (e.g. behind a corporate MITM-terminating proxy), point ca_file: at a PEM bundle.

Error handling

Errors are dispatched by class — the rescue-friendly path:

require "poli_page"

begin
  client.render.pdf(project: "billing", template: "invoice", version: "1.0.0", data: data)
rescue PoliPage::AuthenticationError, PoliPage::PermissionDeniedError => e
  refresh_credentials(e)
rescue PoliPage::RateLimitError => e
  queue_for_later(e.request_id)
rescue PoliPage::ValidationError => e
  logger.error("Bad input: #{e.message}")
rescue PoliPage::GoneError => e
  mark_document_gone(e.code)
rescue PoliPage::Error => e
  raise
end

Predicate helpers on the base class are kept for callers who prefer a single rescue PoliPage::Error => e clause:

rescue PoliPage::Error => e
  return refresh_credentials       if e.auth_error?
  return queue_for_later           if e.rate_limit_error?
  logger.error(e.code, e.status, e.request_id)
  raise unless e.retryable?        # SDK already retried up to max_retries
end

For lifecycle and billing failures, route the user to actionable messages rather than treating them as opaque:

rescue PoliPage::Error => e
  case e.code
  when "PAYMENT_REQUIRED"       then show_banner("Subscription has unpaid invoices.")
  when "ORGANIZATION_CANCELLED" then show_banner("Subscription cancelled — service is read-only.")
  when "ORGANIZATION_PURGED"    then show_banner("Organization has been purged.")
  when "DOCUMENT_NOT_FOUND"     then render_404
  when "GONE"                   then render_410   # document was soft-deleted
  else
    raise
  end
end

The full list of known API codes lives in PoliPage::ErrorCodes (see lib/poli_page/errors.rb).

→ Full error reference: https://poli-page.github.io/sdk-ruby/reference/errors/

Cancellation

Ruby has no async cancellation primitive comparable to AbortSignal. Three mechanisms are available:

  • Per-request timeout via the constructor's timeout: option — the most common case.
  • Timeout.timeout wrapping for caller-side deadlines:
    begin
      pdf = Timeout.timeout(10) { client.render.pdf(...) }
    rescue Timeout::Error
      # ... handle the deadline; the SDK does NOT translate this into PoliPage::TimeoutError
    end
  • Thread#raise from a sibling thread for advanced cases; the SDK's blocking sleep between retries honours the interrupt.

For streaming methods, the block is the cancellation point: break or raise out of the block to stop reading mid-stream.

Observability

Two hooks fire at well-defined points. They're synchronous, optional, and never break the request:

client = PoliPage::Client.new(
  api_key:  ENV.fetch("POLI_PAGE_API_KEY"),
  logger:   Logger.new($stdout),
  on_retry: ->(event) { Statsd.increment("polipage.retry", tags: ["code:#{event.reason.code}"]) },
  on_error: ->(err)   { Sentry.capture_exception(err) }
)

Hook exceptions are swallowed — a broken metrics path never crashes a render. The injected logger: works with any Logger-compatible object (lograge, ougai, semantic_logger, etc.).

Retries & idempotency

The SDK retries on 5xx, 429, network errors, and timeouts. Backoff is exponential (retry_delay * 2**(attempt - 1)) with jitter in [0.5, 1.5), capped by Retry-After when the server provides it (integer seconds or HTTP-date; capped at 30 s).

Every POST sends an auto-generated Idempotency-Key (UUID v4); pass idempotency_key: to override.

client.render.pdf(project: "billing", template: "invoice", version: "1.0.0",
                  data: data, idempotency_key: "render-INV-001-2026-05")

Type system

The gem ships RBS signatures under sig/. Steep checks them against the implementation in CI. Consumers can opt into project-mode validation via required kwargs at runtime — Client#render.pdf raises ArgumentError immediately if project:, template:, or data: are omitted.

For Sorbet users: .rbi shipping is a post-1.0 enhancement.

Concurrency & thread-safety

A single PoliPage::Client instance is safe to share across threads. Configuration is immutable after #initialize; each request opens its own Net::HTTP connection.

CLIENT = PoliPage::Client.new(api_key: ENV.fetch("POLI_PAGE_API_KEY")).freeze

pdfs = ids.map do |id|
  Thread.new { CLIENT.render.pdf(project: "billing", template: "invoice", version: "1.0.0", data: { invoice_number: id }) }
end.map(&:value)

Build the client once at boot. Don't construct a new client per request.

Runtime support

  • Ruby 3.1+ (CRuby/MRI). Latest two minor releases tested in CI.
  • JRuby 9.4+ on a best-effort basis.
  • Linux and macOS in CI. Windows is supported but not exercised in CI.

Requirements

Ruby >= 3.2. Tested in CI against 3.2, 3.3, and 3.4 on Ubuntu, plus one job each on macOS and Windows with 3.4.

Documentation & support

License

MIT © Poli Page