software · Smoke Workspace
How to use Rodmena® PDF API
pdfapi — zero-retention document-to-PDF conversion API
Base URL: https://pdfapi.rodmena.co.uk · version 0.1.0 · OpenAPI: https://pdfapi.rodmena.co.uk/openapi.json
Everything you need is below; the response shapes are exact.
What this is. Upload a document, get a document id, ask for a read token, poll status, download the PDF once. Then it is gone. Nothing is retained: the source is deleted the moment conversion finishes, the PDF is deleted the moment it is downloaded (or explicitly deleted, or 24 h after upload, whichever comes first), and no log line ever carries content, filenames, credentials or tokens.
1. Two credentials, two jobs
| Credential | Where | Used on |
|---|---|---|
Basic auth — username = your client_id, password = your rak_… API key |
Authorization: Basic base64(client_id:rak_…) |
upload, token issue, delete, key management |
| Document token — a per-document bearer capability you request after upload | Authorization: Bearer <token> |
status, download |
Status and download need ONLY the document token, so you can hand that token to whoever needs the PDF without sharing your API key. Tokens expire after 15 minutes (never beyond the document itself); request another whenever you like.
Never put a token or key in a URL. They travel in the Authorization header only.
2. Getting a client (self-service)
curl -X POST https://pdfapi.rodmena.co.uk/v1/clients -H 'Content-Type: application/json' \
-d '{"name": "my-app", "email": "you@example.com"}'
# -> 201 {"client_id": "c_01J…", "api_key": "rak_…", "key_id": "…", "note": "…"}
The api_key is shown exactly once. Registration is unauthenticated and capped
per source IP and globally per day; a 429 here means wait for Retry-After.
Key lifecycle (Basic auth):
GET /v1/clients/me/keys -> {"client_id", "keys": [{key_id, key_prefix, label, is_active, …}]}
POST /v1/clients/me/keys {"label"?} -> 201 {"client_id", "key_id", "api_key", "key_prefix"}
DELETE /v1/clients/me/keys/{key_id} -> {"key_id", "revoked", "already_revoked"}
A revoked key stops working within 30 seconds (the verifier caches positive verdicts for that long, never negative ones).
3. The flow
# 1. upload (multipart; the part's Content-Type declares the format, or send a `format` field)
curl -u "$CLIENT_ID:$API_KEY" -X POST https://pdfapi.rodmena.co.uk/v1/documents \
-F 'file=@invoice.html;type=text/html' -F 'page_size=A4'
# -> 201 {"document_id": "01J…", "state": "queued", "expires_at": "…Z",
# "status_url": "…/v1/documents/01J…", "token_url": "…/v1/documents/01J…/token"}
# 2. token
curl -u "$CLIENT_ID:$API_KEY" -X POST https://pdfapi.rodmena.co.uk/v1/documents/$DOC/token
# -> 201 {"token": "…", "expires_at": "…Z", "document_id": "01J…"}
# 3. poll
curl -H "Authorization: Bearer $TOKEN" https://pdfapi.rodmena.co.uk/v1/documents/$DOC
# -> 200 {"document_id", "state": "queued|processing|ready|failed|retrieved|deleted|expired",
# "format", "created_at", "expires_at", "error": null | {"code", "detail"},
# "attempts", "pages", "output_bytes"}
# 4. download — ONCE
curl -H "Authorization: Bearer $TOKEN" -o out.pdf --fail https://pdfapi.rodmena.co.uk/v1/documents/$DOC/pdf
# -> 200 application/pdf (Content-Disposition: attachment; filename="<document_id>.pdf")
# optional: purge early (Basic)
curl -u "$CLIENT_ID:$API_KEY" -X DELETE https://pdfapi.rodmena.co.uk/v1/documents/$DOC # -> 204
Poll every 2 s; conversion usually finishes in a few seconds for HTML and under
30 s for office documents. The /pdf route answers 409 not_ready with
Retry-After while the job is queued or processing — deliberately not a 2xx,
so curl --fail -o out.pdf never saves a JSON body as a PDF.
The download is a single read. The moment /pdf answers 200 the file is
unlinked on the server. If your connection drops mid-transfer, the document is
gone and you re-upload. There is no second chance, by design.
Idempotent uploads. Send Idempotency-Key: <your unique key> on the upload;
a repeat within 24 h with the same key returns the same document (200 with
Idempotent-Replayed: true) and is charged once.
4. Formats and rendering options
| Declared type | Also accepted as format= |
Engine |
|---|---|---|
text/html, application/xhtml+xml |
html, htm |
Chromium (print media, A4 default) |
application/vnd.openxmlformats-officedocument.wordprocessingml.document |
docx |
LibreOffice |
application/msword |
doc |
LibreOffice |
application/vnd.oasis.opendocument.text |
odt |
LibreOffice |
application/rtf, text/rtf |
rtf |
LibreOffice |
text/plain |
txt |
LibreOffice |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
xlsx |
LibreOffice |
application/vnd.openxmlformats-officedocument.presentationml.presentation |
pptx |
LibreOffice |
application/pdf |
pdf |
pikepdf normalise (strips OpenAction/JavaScript/embedded files) |
The declared type and the file’s actual bytes must agree, or you get 415
unsupported_media_type. application/octet-stream without a format field is
refused. Encrypted PDFs (owner or user password) are refused with 422
invalid_document.
Multipart fields for HTML rendering: javascript (default false), page_size
(A3|A4|A5|Letter|Legal|Tabloid), landscape (false), margin_mm (15,
0–50), print_background (true), scale (1.0, 0.1–2.0).
HTML documents render with NO network access. Every external resource —
images, stylesheets, fonts, scripts, iframes — is blocked; only inline content
and data: URIs render. Embed what you need. This is what makes it safe to send
us untrusted HTML, and it is not configurable.
5. Limits
Upload cap: 20 MiB per document (413
payload_too_large, also enforced at the edge).Output cap: 50 MiB / 500 pages (a larger result is a 422
conversion_failed).Render timeouts: HTML 30 s, office 90 s, PDF 20 s.
Retention: 24 h maximum from upload; tokens 15 min.
Quotas and rate limits are per client and answered as 429 with
Retry-After; when the queue or ephemeral storage is full you get 503queue_full/storage_fullwithRetry-Afterand nothing is charged.
6. Errors — RFC 7807 problem+json with a stable code
{"type": "https://pdfapi.rodmena.co.uk/problems/not-ready", "title": "The PDF is not ready yet",
"status": 409, "code": "not_ready", "state": "processing"}
Branch on code, not on prose:
| status | code | meaning |
|---|---|---|
| 400 | invalid_multipart, invalid_page_options, invalid_idempotency_key |
fix the request |
| 401 | unauthenticated, invalid_credentials |
Basic credentials missing or rejected (WWW-Authenticate: Basic) |
| 401 | token_invalid, token_expired |
document token missing/unknown/expired (WWW-Authenticate: Bearer) |
| 404 | document_not_found |
unknown id, or not your document |
| 409 | not_ready |
still queued/processing — honour Retry-After |
| 409 | key_limit_reached |
25 active keys; revoke one |
| 410 | document_gone |
already retrieved, deleted or expired (state says which) |
| 413 | payload_too_large |
over the 20 MiB cap |
| 415 | unsupported_request_content_type |
body is not multipart/form-data |
| 415 | unsupported_media_type |
declared type unsupported or disagrees with the bytes |
| 422 | validation_error |
see errors |
| 422 | invalid_document |
empty, encrypted, zip bomb, unparsable |
| 422 | conversion_failed |
the renderer rejected the input (not refunded) |
| 429 | rate_limited, quota_exceeded |
wait for Retry-After / reset_at |
| 500 | internal_error |
our fault; a failed conversion of this kind is refunded |
| 503 | backend_unavailable |
a dependency is down; we fail closed, never unmetered or unauthenticated |
| 503 | queue_full, storage_full |
back off for Retry-After; nothing was charged |
A failed document’s status carries error.code = conversion_failed (your
input) or internal_error (ours, refunded). GET /pdf on it answers 422 or 500
accordingly.
7. Service
| Path | Auth | Notes |
|---|---|---|
| `GET | HEAD /healthz, /ping` |
no |
| `GET | HEAD /readyz` | no |
| `GET | HEAD /llms.txt` | no |
| `GET | HEAD /robots.txt` | no |
GET /docs, /openapi.json |
no | interactive docs |
Every response carries X-Request-Id (quote it when reporting a problem) and
X-Robots-Tag: noindex.