Password-protecting a generated PDF, and opening it later
Two passwords do different jobs, and permissions only bind polite readers. What encryption buys you and what it doesn't.
POST /v1/pdf/encrypt on Infrai takes a user_password, an owner_password and a permissions set, and POST /v1/pdf/decrypt reverses it given the password. The two passwords are not interchangeable and the distinction is the first thing to get right: the user password is required to open the document, the owner password is required to change its restrictions.
A document with only an owner password opens for anyone. That surprises people.
Encrypt
curl -sS -X POST "https://api.infrai.cc/v1/pdf/encrypt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"pdf": "pdf_2fVc8nRqLmT4xBzY",
"user_password": "the-recipient-needs-this",
"owner_password": "only-we-have-this",
"permissions": ["print"],
"store": true
}'
{
"ok": true,
"data": {
"pdf_id": "pdf_9wQ1zV6pLkS3dHyB",
"url": "https://files.infrai.cc/pdf/9wQ1zV6pLkS3dHyB.pdf",
"size_bytes": 186368,
"page_count": 4,
"sha256": "9f2c41bd7a9e8c0b5d3e1f7a2b8d4c6e0a9f3b1d5c7e2a4f6b8d0c2e4a6f8b0d",
"created_at": "2026-09-21T03:45:00Z",
"retention_days": 7,
"source": "encrypt"
}
}
The input is a pdf_id from an earlier call, so encryption chains onto generation without the bytes coming back to you: generate the statement, encrypt it, send it.
| Password set | Effect |
|---|---|
| user only | needs a password to open; anyone who opens it can change restrictions |
| owner only | opens freely; restrictions are enforced by cooperating readers |
| both | needs a password to open, and restrictions are locked |
| neither | nothing happens — don’t call encrypt |
Set both for anything confidential. The common mistake is setting only the owner password, believing the document is protected, and discovering that every PDF reader opens it without asking.
The statement pipeline
import os
import secrets
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def generate_statement(customer: str, html: str) -> str:
resp = SESSION.post(f"{API}/v1/pdf/generate",
json={"html": html, "page_size": "A4", "store": True}, timeout=120)
resp.raise_for_status()
return resp.json()["data"]["pdf_id"]
def protect(pdf_id: str, user_password: str) -> dict:
"""Both passwords, always. The owner password is ours and never leaves this
process; the user password is what the recipient needs and is sent through a
different channel from the document itself."""
resp = SESSION.post(
f"{API}/v1/pdf/encrypt",
json={"pdf": pdf_id, "user_password": user_password,
"owner_password": os.environ["PDF_OWNER_PASSWORD"],
"permissions": ["print"], "store": True},
timeout=120,
)
resp.raise_for_status()
return resp.json()["data"]
def deliver(email: str, protected: dict, hint: str) -> bool:
"""Send the document. The password does NOT go in this message — emailing both
halves together is the same as sending it unencrypted, and is the most common
way this whole exercise is wasted."""
resp = SESSION.post(
f"{API}/v1/email/send",
json={"to": email, "subject": "Your statement",
"html": f"<p>Your statement is attached. {hint}</p>",
"message_class": "transactional"},
timeout=60,
)
return bool(resp.ok)
def issue_statement(email: str, customer: str, html: str, known_secret: str) -> dict:
pdf_id = generate_statement(customer, html)
protected = protect(pdf_id, user_password=known_secret)
deliver(email, protected, hint="Open it with the postcode we hold on file.")
return {"pdf_id": protected["pdf_id"], "sha256": protected["sha256"]}
if __name__ == "__main__":
print(issue_statement("ada@example.com", "Ada Lovelace",
"<h1>Statement</h1><p>September 2026</p>",
known_secret=secrets.token_hex(4)))
The password-delivery comment is the important one. A document encrypted with a password sent in the same email is theatre.
Choosing the user password
Something the recipient already knows beats something you generate. A postcode, the last four digits of an account number, a date of birth — none are secrets in the cryptographic sense, but they’re known to the recipient and not present in the email, which is the actual threat model for a misdelivered statement.
If you do generate one, deliver it out of band: POST /v1/sms/send on the same key puts it on the recipient’s phone while the document is in their inbox. One credential, two channels, and neither message is sufficient on its own.
Decrypt
curl -sS -X POST "https://api.infrai.cc/v1/pdf/decrypt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"pdf": "https://files.example.com/inbox/statement-locked.pdf", "password": "the-recipient-needs-this", "store": true}'
Useful when you are the recipient: a supplier sends a protected invoice and your pipeline needs the text. Decrypt, then POST /v1/pdf/parse for the contents.
What encryption doesn’t do
Permissions like “don’t print” and “don’t copy” are requests, not enforcement. They’re flags in the document that cooperating readers honour; a tool that ignores them prints anyway, and tools that ignore them are freely available. Treat permissions as a signal of intent rather than a control.
Encryption also protects the document at rest, not the act of sharing. Once the recipient has opened it, they have the content — there’s no expiry, no revocation and no audit of who opened what. If those are your requirements, a document-sharing platform with access control is the right tool and this isn’t a good fit.
Limitations
There’s no per-recipient key management here: one document, one user password, so sending the same statement to two people with different passwords means encrypting it twice. There’s also no way to change a password without decrypting and re-encrypting, and no visibility into which encryption standard a received document used beyond whether decrypt succeeds.
Anvil and DocRaptor both offer more around secure document delivery, including hosted access links, and a dedicated secure-file-transfer product does revocation and audit properly. What you get here is the chain on one credential: generate, encrypt, deliver by email or SMS, archive with PUT /v1/storage/object/put/{bucket}/{key}, and one GET /v1/account/usage covering all of it. Encryption and decryption bill per call, live in GET /v1/discovery/pdf.encrypt (verified 2026-09-21), with rates drifting downward as vendor contracts improve.