Poli Page SDK for Ruby
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-pageRequires Ruby >= 3.2.
The gem's one runtime dependency is base64 (a bundled gem since Ruby
3.4, used to decode thumbnail data); everything else is the stdlib
(net/http, json, uri, securerandom, logger, openssl).
Quick start
Render a template by slug
Set POLI_PAGE_API_KEY to a sandbox key of a fresh organization
(pp_sandbox_…, see Authentication & environments),
then:
require "poli_page"
client = PoliPage::Client.new(api_key: ENV.fetch("POLI_PAGE_API_KEY"))
pdf = client.render.pdf(
project: "getting-started",
template: "welcome",
data: { name: "SDK Demo" }
)
# pdf is a binary-encoded StringEvery organization gets a getting-started project, published into its
sandbox environment when the organization is created, with a welcome
template. A sandbox key renders it with no setup, so the snippet above runs
as-is on a fresh organization. It is a normal, editable project: in an
organization that renamed it, deleted it or removed it from sandbox, the
API answers 404 PROJECT_NOT_FOUND or 404 PROJECT_NOT_IN_ENVIRONMENT, so
use a fresh organization to follow along.
There is no version to pick: a render uses the project's copy published in the API key's environment. For your own templates, publish the project to the environment of the key you render with, then swap the slugs:
pdf = client.render.pdf(
project: "billing",
template: "invoice",
data: { invoiceNumber: "INV-001", total: 1280 }
)To preview a template or render inline HTML while you design it, use the playground: https://play.poli.page. The API and this SDK render published templates only.
Write a PDF to disk
client.render_to_file(
"./welcome.pdf",
project: "getting-started", template: "welcome",
data: { name: "SDK Demo" }
)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.rbThe demo renders getting-started/welcome, walks every public method
end-to-end and writes the results to examples/output/. First run prompts
for a sandbox key (pp_sandbox_…) 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; POLI_PAGE_BASE_URL (optional) overrides the API host.
It walks nine steps: render.pdf → render.pdf_stream → render_to_file
→ render.document → documents.get → documents.thumbnails →
documents.preview → documents.delete → not-found (a render of a
template that does not exist, caught as PoliPage::NotFoundError with code
TEMPLATE_NOT_FOUND).
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",
data: { invoiceNumber: "INV-001" }
) do |chunk|
io.write(chunk)
end
endWithout a block, pdf_stream returns an Enumerator that composes with
Enumerable:
enum = client.render.pdf_stream(project: "billing", template: "invoice", 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",
data: { invoiceNumber: "INV-001" },
metadata: { customerId: "cust_123" } # your own audit data
)
# doc.document_id, doc.page_count, doc.size_bytes, doc.presigned_pdf_url, ...
# doc.metadata is returned as stored: { "customerId" => "cust_123" }
# The keys of data: and metadata: are sent exactly as written (no renaming).
# 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
An API key belongs to exactly one environment of your organization, and its
text names that environment: a key is pp_{env}_{32 lowercase hex}, matching
^pp_(?=[a-z][a-z0-9-]{0,19}_)[a-z][a-z0-9]*(?:-[a-z0-9]+)*_[0-9a-f]{32}$
The API resolves the environment from the key and renders the project's copy published there; the SDK never sends an environment.
| Environment | Key | Watermark | Counted |
|---|---|---|---|
sandbox (every org) |
pp_sandbox_… |
yes | no |
live (every org, deletable on Pro+) |
pp_live_… |
no | yes |
created on Pro+ (e.g. staging) |
pp_staging_… |
no | yes |
"Counted" means the render counts toward the organization's monthly PDF
quota. sandbox renders are watermarked, are not counted, and their
documents expire 30 days after creation. There is no watermark option: a
template that wants a watermark outside sandbox uses the poli-watermark
class.
Standard and service keys share this one format. Every key hits the same
endpoint, https://api.poli.page by default or whatever you pass as
base_url: (see Configuration). The SDK passes the key
through as a Bearer token and does not check its format.
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.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 (JPEG/PNG/WebP, base64) |
client.documents.delete(id) |
nil |
Soft-delete a stored document (idempotent) |
client.render_to_file(path, **input) |
nil |
Render and stream to disk |
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
api_key: |
String |
(required) | API key of one environment (pp_{env}_…) |
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-attempt timeout, body included |
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", 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
endPredicate helpers on the base class are kept for callers who prefer a
single rescue PoliPage::Error => e clause. They read status and code
(auth_error? is 401/403, rate_limit_error? 429, validation_error? 400,
network_error? network_error/timeout), so a DownloadError with a
storage 403 is an auth_error?:
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
endFor lifecycle and billing failures, route the user to actionable messages rather than treating them as opaque:
rescue PoliPage::Error => e
case e.code
when "ORGANIZATION_PAYMENT_GRACE" 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 "DOCUMENT_GONE" then render_410 # deleted, or a sandbox document past 30 days
else
raise
end
endThe 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.timeoutwrapping 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#raisefrom a sibling thread for advanced cases; the SDK's blockingsleepbetween 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,
except QUOTA_EXCEEDED, OVERAGE_CAP_EXCEEDED and STORAGE_ACCESS_DENIED,
which wait cannot fix. The presigned-URL download is never retried.
Backoff is exponential (retry_delay * 2**(attempt - 1)) with jitter in
[0.5, 1.5), replaced by Retry-After when the server provides it
(integer seconds or HTTP-date; clamped to 0–30 s). timeout: bounds each
attempt, body included, and every resend is one of these counted retries:
Net::HTTP never sends a request again on its own.
Every POST sends an auto-generated Idempotency-Key (UUID v4), the same on
every retry of one call; pass idempotency_key: to override. The API does
not deduplicate on it: a render retried after a late 5xx, a timeout or a
network_error can store a second document.
client.render.pdf(project: "billing", template: "invoice",
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. The render methods take keyword arguments, so Ruby
checks them at call time — Client#render.pdf raises ArgumentError
before any request if project:, template:, or data: is omitted, or if
an unknown keyword (such as the removed version:) is passed.
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 attempt opens its
own Net::HTTP connection (no pool or cache to size), plus a short-lived
thread that enforces timeout:.
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", data: { invoiceNumber: id }) }
end.map(&:value)Build the client once at boot. Don't construct a new client per request.
Runtime support
- Ruby 3.2+ (CRuby/MRI).
- JRuby 9.4+ on a best-effort basis.
- Linux, macOS and Windows (see Requirements for the CI matrix).
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
- Platform docs: docs.poli.page
- SDK documentation: poli-page.github.io/sdk-ruby
- Sign up & generate API keys: app.poli.page
- Issues: github.com/poli-page/sdk-ruby/issues
License
MIT © Poli Page