---
title: Public API
description: Current /api/v1 endpoints, authentication, payload shapes, errors,
  and integration semantics.
editUrl: true
head: []
tableOfContents:
  minHeadingLevel: 2
  maxHeadingLevel: 4
template: doc
sidebar:
  order: 2
  label: Public API
  hidden: false
  attrs: {}
pagefind: true
draft: false
---

The Plainterms public API is rooted at `/api/v1`. Every current endpoint
requires an organization API key in the `Authorization` header:

```http
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

| 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](#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

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

Returns the organization and API-key identity for the supplied Bearer key.

```json
{
  "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

Returns users in the API key's organization. The list is ordered by last name,
then first name.

```json
{
  "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

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](#handling-duplicate-uploads).

### Request

```json
{
  "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

`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

Status: `202 Accepted`

```json
{
  "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](https://github.com/pagerguild/plainterms/issues/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](#handling-duplicate-uploads).

### Integration Semantics

- `user_email` must match a user profile in the API key's organization.
- The matching user becomes the document owner (`created_by`) for Plainterms
  dashboard visibility and webhook `uploaded_by` attribution.
- 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](#handling-duplicate-uploads)). When the existing
  document already has a `customer_document_id`, duplicate or retry uploads
  should send the same value.
- `customer_document_id` is separate from `idempotency_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_id` that already belongs to a different
  document in the organization returns `409`. If this happens during a
  duplicate or retry upload, look up the existing logical document and resend
  its original `customer_document_id` instead of issuing a new one.

### Handling Duplicate Uploads

:::note
Verified 2026-08-01 against production behavior by the reference integration
([sample-plainterms-integration-app](https://github.com/pagerguild/sample-plainterms-integration-app))
and against the platform source's reservation contract. Formal contract
documentation is tracked in
[pagerguild/plainterms#2](https://github.com/pagerguild/plainterms/issues/2).
:::

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](#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:

```json
{
  "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"`** (the `upload` object is present) — `PUT` the
  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"`** (the `upload` object is omitted) — the document's
  content is already satisfied server-side: same-document reuse, or a sibling
  whose content was cloned. Do not attempt a `PUT`. Fetch the document's
  current state with `GET /api/v1/documents/{documentId}` (the `document.id`
  in the response) and treat the upload as succeeded.
- **No `upload` object with any other `next_action`** — treat as an error and
  surface the response for diagnosis rather than fabricating success.

#### 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).

### 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

Use either Plainterms' document UUID or your own `customer_document_id`.

```http
GET /api/v1/documents/00000000-0000-4000-8000-000000000020
GET /api/v1/documents/custId-crm-quote-123
```

For 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

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.

```json
{
  "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

| 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

Downloads the rendered branded PDF for a ready document. This endpoint requires
the same API key authentication as the JSON endpoints.

```http
GET /api/v1/documents/00000000-0000-4000-8000-000000000020.pdf
Authorization: Bearer ptk_<lookup>.<secret>
```

By default the response is an attachment. Add `?disposition=inline` when the
client wants an inline PDF response.

### Ready Response

Status: `200 OK`

```http
Content-Type: application/pdf
```

The response body is the PDF bytes.

### Not-Ready Responses

When the PDF is still running or retrying, Plainterms returns `202 Accepted`
with `Retry-After` and JSON:

```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

Records threshold confirmation for a document. Use this when a downstream
system has collected the required human confirmation and needs to notify
Plainterms.

### Request

Send exactly one document identifier plus `confirmed_by_email`.

```json
{
  "customer_document_id": "crm-quote-123",
  "confirmed_by_email": "manager@example.com"
}
```

or:

```json
{
  "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

```json
{
  "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`.