Public API
The Plainterms public API is rooted at /api/v1. Every current endpoint
requires an organization API key in the Authorization header:
Authorization: Bearer ptk_<lookup>.<secret>API keys identify the organization. They do not identify a Plainterms user. Endpoints that act on behalf of a user take a user email and resolve that email inside the API key’s organization.
Current Endpoints
Section titled “Current Endpoints”| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/me | Validate the key and return the organization/key identity. |
GET | /api/v1/users | List Plainterms users in the key’s organization. |
POST | /api/v1/documents | Reserve a PDF upload for a same-organization user. Every 202 carries a reservation whose next_action says whether to PUT to a signed URL ("upload") or skip the upload entirely ("none") — see Handling Duplicate Uploads. |
GET | /api/v1/documents/{documentId} | Read document metadata, polling state, confirmation state, and the API-key-authenticated PDF download resource. |
GET | /api/v1/documents/custId-{customerDocumentId} | Read the same metadata by customer-supplied document ID. |
GET | /api/v1/documents/{documentId}.pdf | Download or poll the rendered branded PDF with the same API key. |
POST | /api/v1/documents/confirm | Record threshold confirmation for a document by Plainterms ID or customer document ID. |
There is no bare /api/v1 route and no public document listing/search,
multipart upload, API-key management, webhook configuration, OpenAPI discovery,
or bearerless rendered-PDF URL endpoint.
Errors
Section titled “Errors”Plainterms uses standard HTTP status codes with text or JSON error messages from the server route. Common codes:
| Status | Meaning |
|---|---|
400 | Request body shape, path identifier, or field validation failed. |
401 | Missing, malformed, unknown, revoked, expired, or wrong API key. |
403 | The referenced user is suspended, or a user-level action is not allowed. |
404 | A user or document was not found inside the API key’s organization. |
409 | The operation conflicts with current document state, confirmation state, or the requested customer_document_id. |
410 | The rendered PDF is permanently unavailable because the document reached a terminal no-download state. |
500 | Plainterms could not load, render, or persist required server state. |
GET /api/v1/me
Section titled “GET /api/v1/me”Returns the organization and API-key identity for the supplied Bearer key.
{ "authenticated_at": "2026-06-24T12:00:00.000Z", "organization": { "id": "00000000-0000-4000-8000-000000000001", "name": "Example Insurance", "slug": "example-insurance" }, "api_key": { "id": "00000000-0000-4000-8000-000000000002", "public_prefix": "ptk_abc123lookupvalue" }}Use this endpoint for startup checks and support diagnostics. Plainterms does not add permissive browser CORS headers for this endpoint.
GET /api/v1/users
Section titled “GET /api/v1/users”Returns users in the API key’s organization. The list is ordered by last name, then first name.
{ "users": [ { "id": "00000000-0000-4000-8000-000000000010", "email": "agent@example.com", "name": "Ava Agent", "first_name": "Ava", "last_name": "Agent", "status": "active" } ]}Use email as the user_email value when reserving a document upload. A
suspended user can appear in this list, but document upload and confirmation
actions reject suspended users.
POST /api/v1/documents
Section titled “POST /api/v1/documents”Reserves a document upload for a same-organization Plainterms user. For new
content this is a two-step upload flow: first reserve the upload through
Plainterms, then PUT the PDF bytes to the signed storage URL in the response.
When the content already exists in the organization, the response can have
no upload object (reservation.next_action: "none") and resolve to an
existing document or a server-side-cloned sibling — see
Handling Duplicate Uploads.
Request
Section titled “Request”{ "user_email": "agent@example.com", "filename": "quote.pdf", "file_size_bytes": 1048576, "mime_type": "application/pdf", "content_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "idempotency_key": "optional-retry-key", "customer_document_id": "crm-quote-123"}| Field | Required | Rules |
|---|---|---|
user_email | Yes | Same-organization Plainterms user email. Case-insensitive after trimming. |
filename | Yes | Non-empty string. |
file_size_bytes | Yes | Positive integer, maximum 20 MB. |
mime_type | Yes | Must be application/pdf. |
content_hash | Yes | SHA-256 hex digest, 64 characters. Plainterms normalizes the value to lowercase before validation. |
idempotency_key | No | String. Defaults to <content_hash>-<filename>. |
customer_document_id | No | String up to 512 characters. Unique within the organization when present. Recommended for production integrations that need customer-side document correlation. |
Do not send uploaded_by; the API uses user_email to set Plainterms
attribution.
Customer Document Identity
Section titled “Customer Document Identity”customer_document_id is your stable identifier for the logical customer
document or quote. It is optional at the API level, but production integrations
should send it when they need CRM, Retool, policy-system, metadata,
confirmation, webhook, or duplicate-safe re-upload correlation.
Choose the value before upload automation goes live and store it with the customer record that owns the document. Good sources include:
- A CRM quote ID, such as
crm-quote-123. - A policy-system document ID.
- A deterministic content-derived document key, such as a PDF hash paired with a customer record ID your system owns.
Do not generate a new random value for every button click, browser attempt, or
upload retry. When a user retries or re-uploads the same logical PDF, resend
the original customer_document_id; rotating it for duplicate content can
return 409.
idempotency_key and customer_document_id are related but not
interchangeable. idempotency_key is the reservation retry key and defaults to
<content_hash>-<filename> when omitted. customer_document_id is the
customer-side document identity that later metadata, confirmation, and webhook
flows can use for correlation. Send both when you have both.
Response
Section titled “Response”Status: 202 Accepted
{ "document": { "id": "00000000-0000-4000-8000-000000000020", "customer_document_id": "crm-quote-123", "storage_path": "org-id/document-id.pdf", "status": "extracting" }, "user": { "id": "00000000-0000-4000-8000-000000000010", "email": "agent@example.com", "name": "Ava Agent" }, "reservation": { "outcome": "created", "document_state": "waiting_for_upload", "next_action": "upload", "reopened_for_reupload": false }, "upload": { "method": "PUT", "url": "https://storage.example/signed-upload-url", "token": "upload-token", "headers": { "content-type": "application/pdf" } }, "extraction": { "status": "started", "workflowRunId": "run-1", "clientRequestId": "document:doc-1:now:text", "uploadReadyHookToken": "document-upload:doc-1:now" }}When upload is present, the caller must upload the PDF bytes with PUT to
upload.url and the listed content-type. Plainterms starts extraction and
waits for the upload to become available. The extraction object is included
when extraction has been started for the reservation.
The reservation object is expected on every 202 response per the
platform’s reservation contract. Production captures confirm it on
duplicate/reuse responses; its presence on fresh created responses is
pending production confirmation in
pagerguild/plainterms#2,
so parse it tolerantly and fall back to upload presence if it is ever
absent. Its next_action field is the dispatch discriminant, with two
current values: "upload"
(the client must PUT to upload.url) and "none" (the document’s content is
already satisfied server-side — the upload object is omitted entirely and
document.status reflects the document’s real status, for example
"approved"). Dispatch on reservation.next_action, never on the presence of
specific outcome strings — see
Handling Duplicate Uploads.
Integration Semantics
Section titled “Integration Semantics”user_emailmust match a user profile in the API key’s organization.- The matching user becomes the document owner (
created_by) for Plainterms dashboard visibility and webhookuploaded_byattribution. - Duplicate content resolves to the existing document instead of creating a
new one: in-flight duplicates can reuse the existing reservation, and settled
duplicates return the existing document with no upload step (see
Handling Duplicate Uploads). When the existing
document already has a
customer_document_id, duplicate or retry uploads should send the same value. customer_document_idis separate fromidempotency_key. It is useful for CRM, Retool, or policy-system correlation, and can later be used with/api/v1/documents/custId-{customerDocumentId}and/api/v1/documents/confirm.- A requested
customer_document_idthat already belongs to a different document in the organization returns409. If this happens during a duplicate or retry upload, look up the existing logical document and resend its originalcustomer_document_idinstead of issuing a new one.
Handling Duplicate Uploads
Section titled “Handling Duplicate Uploads”When POST /api/v1/documents receives content that already exists in the
organization (matched by content_hash), the response is normally still
202 Accepted, with the reservation object describing what happened and
what the client should do. The one exception: a duplicate arriving under a
fresh customer_document_id in an environment without sibling uploads
(the default) returns the 409 identity conflict instead — handle both
outcomes on that branch (see the Sibling Documents subsection and
Troubleshooting Customer ID Conflicts
below). For same-document reuse (the existing document is
returned), nothing is re-extracted and no state on the existing document
changes, and the response contains no upload object:
{ "document": { "id": "e03397ce-41de-4247-89a0-000087ebd3ed", "customer_document_id": null, "metadata": { "external_document_id": "sample-quote-e2fec121215f9ef272f67dd8ca4d0b45" }, "storage_path": "org-id/e03397ce-41de-4247-89a0-000087ebd3ed.pdf", "status": "approved", "reservation_outcome": "duplicate_reuse", "integration_state": "ready" }, "reservation": { "outcome": "duplicate_reuse", "document_state": "ready", "next_action": "none", "reopened_for_reupload": false }, "user": { "id": "…", "email": "agent@example.com", "name": "…" }}Current reservation.outcome values:
| Outcome | Meaning |
|---|---|
created | New content; a new document was created and the bytes must be uploaded. |
idempotent_reuse | Same idempotency_key as an existing document (a straight retry); resolves to that document. |
duplicate_reuse | Same content_hash as an existing document originally reserved under a different idempotency_key; resolves to that document. |
reopened_for_reupload | An existing document was reopened for recovery; a fresh upload reservation is issued (next_action: "upload"). |
duplicate_content | Same organization content arriving under a fresh, unclaimed customer_document_id created a new sibling document (feature-gated; see below). |
Treat the outcome field as an open enum. The set has grown twice in one
week; clients must never gate behavior on specific outcome strings. Log the
value for observability and dispatch on reservation.next_action — a
two-value union:
next_action: "upload"(theuploadobject is present) —PUTthe bytes as usual. This covers new content (created), recovery (reopened_for_reupload: true), and sibling creations whose content could not be cloned server-side.next_action: "none"(theuploadobject is omitted) — the document’s content is already satisfied server-side: same-document reuse, or a sibling whose content was cloned. Do not attempt aPUT. Fetch the document’s current state withGET /api/v1/documents/{documentId}(thedocument.idin the response) and treat the upload as succeeded.- No
uploadobject with any othernext_action— treat as an error and surface the response for diagnosis rather than fabricating success.
Sibling Documents (duplicate_content)
Section titled “Sibling Documents (duplicate_content)”When sibling uploads are enabled for the environment, re-uploading content
that already exists in the organization under a new customer_document_id
no longer returns the customer-ID 409 — it creates a new sibling
document with its own document.id, bound to the new customer ID. The
sibling’s content is normally cloned server-side (next_action: "none", no
upload step), but when server-side cloning is not possible the response falls
back to a normal upload reservation (next_action: "upload" with an upload
object) — always honor next_action rather than assuming duplicates never
need bytes. A sibling is a real new document: it runs extraction and emits the
normal document lifecycle webhooks. Where sibling uploads are not enabled, the
fresh-customer-ID case keeps returning the 409 identity conflict described
below.
Duplicate-content reuse is separate from customer_document_id identity
conflicts, which still return 409 — see
Troubleshooting Customer ID Conflicts.
Troubleshooting Customer ID Conflicts
Section titled “Troubleshooting Customer ID Conflicts”If POST /api/v1/documents returns 409 and the request includes
customer_document_id, check whether the same logical document already exists
inside the organization with a different customer ID. For same-document retries
or duplicate re-uploads, resend the existing document’s original
customer_document_id.
This is separate from PDF download or confirmation-state 409 responses. Those
responses include polling or confirmation details for the current document
state; upload-reservation customer ID conflicts mean the integration is asking
Plainterms to attach a new customer identity to an existing logical document.
GET Document Metadata
Section titled “GET Document Metadata”Use either Plainterms’ document UUID or your own customer_document_id.
GET /api/v1/documents/00000000-0000-4000-8000-000000000020GET /api/v1/documents/custId-crm-quote-123For the customer-ID route, the value is the part after custId-. Use URL-safe
identifiers when possible, or URL-encode the identifier in that path segment.
Response
Section titled “Response”Metadata responses are JSON and return 200 OK when the document is found.
When the document is still in flight or retrying, use
document.polling.retry_after_seconds from the body as the polling hint.
{ "document": { "id": "00000000-0000-4000-8000-000000000020", "customer_document_id": "crm-quote-123", "status": "approved", "filename": "quote.pdf", "created_at": "2026-06-27T11:30:00.000Z", "uploaded_at": "2026-06-27T11:30:00.000Z", "updated_at": "2026-06-27T11:35:00.000Z", "uploaded_by": { "id": "00000000-0000-4000-8000-000000000010", "email": "agent@example.com" }, "confirmation": { "state": "not_required", "acknowledged_at": null, "acknowledged_by": null }, "reason": null, "extracted_data": { "carrier": { "name": "Progressive" } }, "value": null, "download": { "available": true, "method": "GET", "url": "https://plainterms.example/api/v1/documents/00000000-0000-4000-8000-000000000020.pdf", "content_type": "application/pdf", "authentication": "api_key" }, "polling": { "state": "ready" } }}The download.url value is a Plainterms API resource. It is not a bearerless
signed URL. Fetch it with the same Authorization: Bearer <API key> header.
Polling States
Section titled “Polling States”| State | Meaning | Client action |
|---|---|---|
in_progress | Upload, extraction, or persistence is still running. | Poll metadata again after retry_after_seconds. |
retrying | A retryable processing failure is being retried. | Poll again after the longer retry hint. |
ready | The rendered PDF can be fetched from download.url. | GET the PDF endpoint with the same API key. |
confirmation_required | A threshold acknowledgment is required before the rendered PDF is available. | Collect confirmation and call POST /api/v1/documents/confirm. |
not_ready | The current document state is not downloadable yet. | Inspect reason and continue only if the upstream state changes. |
terminal | Processing ended in a terminal no-download state. | Treat reason as the terminal explanation. |
GET /api/v1/documents/{documentId}.pdf
Section titled “GET /api/v1/documents/{documentId}.pdf”Downloads the rendered branded PDF for a ready document. This endpoint requires the same API key authentication as the JSON endpoints.
GET /api/v1/documents/00000000-0000-4000-8000-000000000020.pdfAuthorization: Bearer ptk_<lookup>.<secret>By default the response is an attachment. Add ?disposition=inline when the
client wants an inline PDF response.
Ready Response
Section titled “Ready Response”Status: 200 OK
Content-Type: application/pdfThe response body is the PDF bytes.
Not-Ready Responses
Section titled “Not-Ready Responses”When the PDF is still running or retrying, Plainterms returns 202 Accepted
with Retry-After and JSON:
{ "error": { "code": "in_progress", "message": "Document PDF is not ready yet." }, "document": { "id": "00000000-0000-4000-8000-000000000020", "customer_document_id": "crm-quote-123", "status": "persisting" }, "polling": { "state": "in_progress", "retry_after_seconds": 10 }}For confirmation-gated or otherwise not-ready states, Plainterms returns 409
with the same error, document, and polling shape. For terminal
no-download states such as unsupported, rejected, or failed-terminal documents,
Plainterms returns 410 Gone. The error.code matches the polling state, such
as confirmation_required, not_ready, or terminal.
If the document is not found inside the API key’s organization, Plainterms
returns 404.
The PDF endpoint accepts the Plainterms document UUID. If the integration only
has customer_document_id, call the customer-ID metadata endpoint first and
then fetch document.download.url.
POST /api/v1/documents/confirm
Section titled “POST /api/v1/documents/confirm”Records threshold confirmation for a document. Use this when a downstream system has collected the required human confirmation and needs to notify Plainterms.
Request
Section titled “Request”Send exactly one document identifier plus confirmed_by_email.
{ "customer_document_id": "crm-quote-123", "confirmed_by_email": "manager@example.com"}or:
{ "document_id": "00000000-0000-4000-8000-000000000020", "confirmed_by_email": "manager@example.com"}| Field | Required | Rules |
|---|---|---|
document_id | Conditional | Plainterms document UUID. Mutually exclusive with customer_document_id. |
customer_document_id | Conditional | Customer identifier. Mutually exclusive with document_id. |
confirmed_by_email | Yes | Same-organization active Plainterms user email. |
Response
Section titled “Response”{ "document": { "id": "00000000-0000-4000-8000-000000000020", "customer_document_id": "crm-quote-123", "confirmation": { "state": "received", "acknowledged_at": "2026-06-25T16:30:00.000Z", "acknowledged_by": { "id": "00000000-0000-4000-8000-000000000011", "email": "manager@example.com" }, "already_acknowledged": false } }, "webhook": { "status": "started", "eventType": "confirmation.received", "runId": "run-confirmation-received" }}If the document was already confirmed, already_acknowledged is true and
webhook is null. If the document does not currently require threshold
acknowledgment, Plainterms returns 409.