> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.cloudraker.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.cloudraker.com/_mcp/server.

# OpenAI-compatible API

Paperwork serves an OpenAI-compatible surface. Set the base URL, send your usual
CloudRaker API key, and pick a `paperwork-*` model. The façade translates each
call into exactly one capability run and returns the result in OpenAI's
`chat.completion` shape.

Use it when your stack already speaks OpenAI: LiteLLM, LangChain, Continue,
Cursor, or the official SDKs. For new code, the native
[capability endpoints](/paperwork/capabilities/extract) give you more control.

This is **not** a chat model. There is no conversation state, no tool calling,
and no token stream. Every request must attach a file. Token counts are always
zero — this API does not bill by token.

## Base URL

| Environment            | Base URL                               |
| ---------------------- | -------------------------------------- |
| Production             | `https://api.paperwork.sh/v1`          |
| Production (alternate) | `https://api.cloudraker.com/openai/v1` |
| Development            | `https://api.dev.raker.one/openai/v1`  |

Both production forms reach the same worker. `api.paperwork.sh` rewrites `/v1/*`
to the internal `/openai/v1/*` prefix, because every OpenAI client appends
`/chat/completions` to a `/v1` base.

## Authentication

Send your [organization API key](/paperwork/developers/authentication) as the
Bearer token. Nothing else changes.

```bash
curl https://api.paperwork.sh/v1/models \
  -H "Authorization: Bearer $CLOUDRAKER_API_KEY"
```

The tenant comes from the key's organization. `OpenAI-Organization`,
`OpenAI-Project`, `OpenAI-Beta`, and `x-stainless-*` headers are ignored, never
rejected. You cannot select another organization with a header.

A missing or bad key returns the OpenAI error shape:

```json
{
  "error": {
    "message": "Missing or malformed Authorization header. Send `Authorization: Bearer <api key>`.",
    "type": "authentication_error",
    "param": null,
    "code": "invalid_api_key"
  }
}
```

## Models

`GET /models` lists them. `GET /models/{model}` returns one.

| Model                     | Capability                                 | Notes                                   |
| ------------------------- | ------------------------------------------ | --------------------------------------- |
| `paperwork-parse`         | [Parse](/paperwork/capabilities/parse)     | Markdown of the document                |
| `paperwork-extract`       | [Extract](/paperwork/capabilities/extract) | Citations off                           |
| `paperwork-extract-cited` | Extract                                    | Citations on                            |
| `paperwork-redact`        | [Redact](/paperwork/capabilities/redact)   | Documents or audio, routed by MIME type |
| `paperwork-auto`          | Parse or extract                           | `response_format` decides               |
| `paperwork`               | Parse or extract                           | Alias of `paperwork-auto`               |

`paperwork-auto` runs extract when `response_format.type` is `json_schema` or
`json_object`. Otherwise it parses.

`paperwork-extract-cited` exists for model pickers. Tools such as Cursor and
Continue cannot send a custom body, so the model name is the only channel for
turning citations on.

An unknown model returns `404`:

```json
{
  "error": {
    "message": "Unknown model 'gpt-4o'. GET /openai/v1/models lists the available models.",
    "type": "invalid_request_error",
    "param": "model",
    "code": "model_not_found"
  }
}
```

## Your first call

Attach a file part and send it. This parses a document to markdown:

```bash
curl https://api.paperwork.sh/v1/chat/completions \
  -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "paperwork-parse",
    "messages": [
      { "role": "user", "content": [
        { "type": "image_url", "image_url": { "url": "https://example.com/invoice.pdf" } }
      ]}
    ]
  }'
```

The response is a standard `chat.completion` with two additions:

```json
{
  "id": "chatcmpl-par_01KYDQJBG32QDWHM5ERG63XGNE",
  "object": "chat.completion",
  "created": 1756500000,
  "model": "paperwork-parse",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "# Invoice 4417\n\n| Item | Amount |\n| --- | --- |\n…",
        "refusal": null,
        "annotations": []
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 0,
    "completion_tokens": 0,
    "total_tokens": 0,
    "cloudraker": {}
  },
  "cloudraker": {
    "runId": "par_01KYDQJBG32QDWHM5ERG63XGNE",
    "object": "parse_run",
    "statusUrl": "https://api.cloudraker.com/v1/runs/par_01KYDQJBG32QDWHM5ERG63XGNE"
  },
  "system_fingerprint": "fp_cloudraker"
}
```

Response headers carry `x-request-id`, `x-cloudraker-run-id`, and
`idempotent-replay: true` on a replay.

### What `content` holds

`content` is always a string, never `null`.

| Model               | `content`                                                                               |
| ------------------- | --------------------------------------------------------------------------------------- |
| `paperwork-parse`   | The markdown. Over the 1 MiB inline limit, a short text with the markdown and JSON URLs |
| `paperwork-extract` | `JSON.stringify` of the extracted value. Several documents give an array                |
| `paperwork-redact`  | `JSON.stringify` of `{files, entities, skipped}`                                        |

Citations, per-document results, and signed output URLs stay in the `cloudraker`
key. They never enter `content`, so `JSON.parse(content)` keeps working.

## File inputs

Walk order is message order. Every file part is collected.

| Content part                                                                | Meaning                                                               |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `{"type":"file","file":{"file_id":"file_…"}}`                               | A Paperwork file id. An inbound `file-XXXX` normalizes to `file_XXXX` |
| `{"type":"image_url","image_url":{"url":"https://…"}}`                      | Register the document from a URL. Any file type, not only images      |
| `{"type":"file","file":{"file_data":"data:…;base64,…","filename":"a.pdf"}}` | Inline bytes. Uploaded for you before the run starts                  |
| `{"type":"image_url","image_url":{"url":"data:…;base64,…"}}`                | Inline bytes                                                          |
| `{"type":"input_audio","input_audio":{"data":"…","format":"mp3"}}`          | Inline audio. `mp3` and `wav`. Transcribed, not diarized              |

Counts: parse and redact take exactly one file. Extract takes 1 to 100. Zero
files is a `400` with `code: "missing_file"`.

```json
{
  "error": {
    "message": "No input file. Attach a file part — `{\"type\":\"file\",\"file\":{\"file_id\":\"…\"}}` or `{\"type\":\"image_url\",\"image_url\":{\"url\":\"https://…\"}}`.",
    "type": "invalid_request_error",
    "param": "messages",
    "code": "missing_file"
  }
}
```

### The Files API

The OpenAI file endpoints work, over the same corpus as
[`/v1/files`](/paperwork/developers/files):

| Method   | Path                                                |
| -------- | --------------------------------------------------- |
| `POST`   | `/files` — `multipart/form-data` with a `file` part |
| `GET`    | `/files` — the list. `has_more` is always `false`   |
| `GET`    | `/files/{id}`                                       |
| `DELETE` | `/files/{id}`                                       |
| `GET`    | `/files/{id}/content` — a `302` to a signed URL     |

`purpose` is accepted, stored, and echoed back. There is one corpus, so it
selects nothing.

```python
from openai import OpenAI

client = OpenAI(
    base_url="https://api.paperwork.sh/v1",
    api_key="<your CloudRaker API key>",
)

uploaded = client.files.create(file=open("invoice.pdf", "rb"), purpose="assistants")

completion = client.chat.completions.create(
    model="paperwork-extract",
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "Pull the totals and the due date."},
            {"type": "file", "file": {"file_id": uploaded.id}},
        ],
    }],
)
print(completion.choices[0].message.content)
```

## Message text

Text parts are joined with a blank line: every `system` and `developer` text
first, then every `user` text. `assistant` and `tool` messages are ignored — the
façade is one-shot and stateless.

| Model                                | Where the text goes                                                 |
| ------------------------------------ | ------------------------------------------------------------------- |
| `paperwork-extract` with a schema    | `instructions`                                                      |
| `paperwork-extract` without a schema | `hints`, which steer schema inference. Truncated to 2000 characters |
| `paperwork-redact`                   | `instructions`                                                      |
| `paperwork-parse`                    | **Dropped.** Parse takes no instructions                            |

A dropped prompt is reported, never silent: the response carries
`cloudraker.ignoredInstructions: true`.

## Structured output

| `response_format`                                        | Effect                                                                                              |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `{"type":"json_schema","json_schema":{"name","schema"}}` | The schema is used as-is. `name` is stored as `metadata["openai.schema_name"]`. `strict` is dropped |
| `{"type":"json_object"}`                                 | No schema. Paperwork infers one, steered by your text                                               |
| `{"type":"text"}` or absent                              | `paperwork-auto` parses instead of extracting                                                       |

```bash
curl https://api.paperwork.sh/v1/chat/completions \
  -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "paperwork-extract-cited",
    "messages": [
      { "role": "user", "content": [
        { "type": "file", "file": { "file_id": "file_01KYDQJBG32QDWHM5ERG63XGNE" } }
      ]}
    ],
    "response_format": {
      "type": "json_schema",
      "json_schema": {
        "name": "invoice",
        "schema": {
          "type": "object",
          "properties": {
            "invoiceNumber": { "type": "string" },
            "total": { "type": "number" }
          }
        }
      }
    }
  }'
```

The [extraction schema dialect](/paperwork/capabilities/extract/schema) is
stricter than OpenAI's. `$defs`, `$ref`, `oneOf`, `anyOf`, `allOf`, `const`, and
`pattern` are refused, the root must be an object, depth is capped at 5, and the
schema must stay under 64 KB. A rejection is a `400` with
`code: "invalid_schema"` and `param: "response_format.json_schema.schema"`.

## Paperwork options

Non-OpenAI knobs live under one namespaced `cloudraker` key. The official SDKs
reach it with `extra_body`.

| Key          | Applies to      | What it does                                                                                                                                                                                                 |
| ------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `citations`  | extract         | Ground each field in the source                                                                                                                                                                              |
| `judge`      | extract         | Run the verification pass. The judge audits cited evidence, so it needs `paperwork-extract-cited` or `cloudraker.citations: true` — on `paperwork-extract`, which pins citations off, `judge: true` is a 400 |
| `unit`       | extract         | What one result covers: `per_document`, `across_documents`, or `rows_per_document`                                                                                                                           |
| `action`     | extract, redact | Run a saved action by slug or id                                                                                                                                                                             |
| `hints`      | extract         | Steer schema inference explicitly                                                                                                                                                                            |
| `categories` | redact          | The PII categories to remove                                                                                                                                                                                 |
| `mode`       | redact          | Document redaction mode: `targeted` or `lines`                                                                                                                                                               |
| `style`      | redact          | Audio redaction style: `beep` or `silence`                                                                                                                                                                   |
| `wait`       | all             | Seconds to hold the call open. 0 to 120                                                                                                                                                                      |
| `ttl`        | all             | Run retention                                                                                                                                                                                                |
| `webhook`    | all             | Notify an endpoint when the run finishes                                                                                                                                                                     |

```python
completion = client.chat.completions.create(
    model="paperwork-redact",
    messages=[{"role": "user", "content": [
        {"type": "file", "file": {"file_id": "file_01KYDQJBG32QDWHM5ERG63XGNE"}},
    ]}],
    extra_body={"cloudraker": {"mode": "targeted", "categories": ["person", "email"]}},
)
```

An unknown key inside `cloudraker` is a `400` with
`param: "cloudraker.<key>"`. Any other key is namespaced on purpose: OpenAI keeps
adding top-level fields.

## Streaming

`stream: true` returns `text/event-stream`. There is no token stream underneath,
so this is a keep-alive wrapper, not fake token output. Use it when the work
takes longer than the synchronous cap.

The sequence is:

1. An SSE comment as the first byte: `: request=req_… status=processing`. Every
   SDK decoder drops comments, so the first *chunk* comes later — size client
   timeouts on the run, not on time-to-first-byte.
2. The same comment every 10 seconds while the run works.
3. When the run resolves, a comment carrying the run id
   (`: run=exr_… request=req_… status=processed`), then a chunk with
   `delta: {"role":"assistant","content":""}`, then one chunk carrying the whole
   `content`.
4. A chunk with `finish_reason: "stop"`.
5. A usage chunk with `"choices": []`, if you sent
   `stream_options: {"include_usage": true}`.
6. `data: [DONE]`.

A failure after the headers sends one `data: {"error":{…}}` frame and closes,
with no `[DONE]`.

`x-request-id` and `idempotent-replay` never reach a streaming client: both are
written after the body starts. The keep-alive comment carries the request id and
the run id instead.

## Usage and cost

`prompt_tokens`, `completion_tokens`, and `total_tokens` are always `0`. This
API does not bill by token, and the public envelope carries no token count.
`usage.cloudraker` passes through the run's own usage, which parse, extract, and
redact do not produce. It is `{}` on every response.

A façade run costs exactly what the same native `/v1` call costs. Read the balance in
**Settings → Billing**.

## Timeouts and long work

A non-streaming call waits 90 seconds by default, a streaming call up to 120.
Set `cloudraker.wait` to change it, up to 120.

If the run is still working at the cap, you get `504`:

```json
{
  "error": {
    "message": "The run did not finish within 90 seconds. Read it at GET https://api.cloudraker.com/v1/runs/exr_01KYDQJBG32QDWHM5ERG63XGNE, or send `stream: true` (or `cloudraker.webhook`) for long work.",
    "type": "server_error",
    "param": null,
    "code": "run_incomplete"
  }
}
```

The run keeps going. Read it at [`GET /v1/runs/{id}`](/paperwork/developers/runs),
or send `cloudraker.webhook` and get told when it finishes. An SDK retry after a
`504` is a replay, not a second run: the façade derives an idempotency key from
your organization, the request body, and the current hour.

## Errors

Errors use OpenAI's envelope, not the [capability
envelope](/paperwork/developers/errors):

```json
{ "error": { "message": "…", "type": "…", "param": null, "code": "…" } }
```

| HTTP          | `type`                  | `code`                     | Cause                                                            |
| ------------- | ----------------------- | -------------------------- | ---------------------------------------------------------------- |
| `400`         | `invalid_request_error` | `invalid_request`          | Malformed body                                                   |
| `400`         | `invalid_request_error` | `invalid_schema`           | The schema breaks the dialect                                    |
| `400`         | `invalid_request_error` | `missing_file`             | No file part                                                     |
| `400`         | `invalid_request_error` | `too_many_files`           | Over the model's file ceiling                                    |
| `400`         | `invalid_request_error` | `unsupported_content_part` | A content part we cannot read                                    |
| `400`         | `invalid_request_error` | `unsupported_parameter`    | A parameter we refuse instead of ignoring                        |
| `400`         | `invalid_request_error` | `context_length_exceeded`  | The dispatch exceeds the size budget                             |
| `401`         | `authentication_error`  | `invalid_api_key`          | Missing, malformed, or invalid key                               |
| `402`         | `insufficient_quota`    | `credits_exhausted`        | The organization is out of credits                               |
| `402`         | `insufficient_quota`    | `feature_not_in_plan`      | The plan does not include this capability                        |
| `403`         | `permission_error`      | `insufficient_permissions` | The key lacks the permission                                     |
| `404`         | `invalid_request_error` | `model_not_found`          | Unknown model                                                    |
| `404`         | `invalid_request_error` | `unknown_url`              | Unknown path on this API                                         |
| `409`         | `invalid_request_error` | `run_expired`              | An idempotent replay of a purged run. Retry: it starts a new run |
| `409`         | `invalid_request_error` | `file_not_ready`           | The bytes are still settling                                     |
| `413`         | `invalid_request_error` | `request_too_large`        | Body over 25 MB                                                  |
| `429`         | `rate_limit_error`      | `rate_limit_exceeded`      | Rate limited. Honor `Retry-After`                                |
| `502`         | `server_error`          | `run_failed`               | The run ended without a usable result                            |
| `504`         | `server_error`          | `run_incomplete`           | The wait cap expired. The run continues                          |
| `503` / `500` | `server_error`          | the underlying code        | Transient. Retry with backoff                                    |

Every error carries `x-request-id`. Both official SDKs surface it as
`response._request_id`. Quote it in support requests.

## Rate limits

The façade draws on the same per-organization budgets as `/v1`: at least 67
requests per minute overall, and 20 per minute on
`POST /chat/completions` and `POST /files`. A `429` keeps its `Retry-After`
header. See [Rate limits](/paperwork/developers/rate-limits).

## Request size

A request body must stay under **25 MB**, on `/chat/completions` and `/files`
alike. For larger documents, register the file first — with `POST /files` here,
or the presigned flow on [`POST /v1/files`](/paperwork/developers/files) — and
send `{"type":"file","file":{"file_id":"…"}}`.

## Ignored and refused parameters

Chat parameters that have no meaning here are ignored, never rejected:
`temperature`, `top_p`, `presence_penalty`, `frequency_penalty`, `logit_bias`,
`seed`, `stop`, `max_tokens`, `max_completion_tokens`, `reasoning_effort`,
`store`, `service_tier`, `user`, `safety_identifier`, `modalities`,
`parallel_tool_calls`, and `prompt_cache_key`. Unknown fields are ignored too, so
an SDK upgrade never breaks a call.

These return a `400` with `code: "unsupported_parameter"`, because silence would
be a lie:

| Parameter                                       | Why                                                                  |
| ----------------------------------------------- | -------------------------------------------------------------------- |
| `n` other than `1`                              | One document, one result                                             |
| `tools`, `functions`, `tool_choice: "required"` | Use the [MCP server](/paperwork/developers/mcp) for tool-driven work |
| `logprobs`, `top_logprobs`                      | There is no token stream                                             |
| `audio`                                         | No audio output                                                      |
| `prediction`                                    | No predicted outputs                                                 |
| `web_search_options`                            | No web search                                                        |
| `stream_options` without `stream: true`         | Matches OpenAI                                                       |

## Clients

| Client                          | Status                                                                  |
| ------------------------------- | ----------------------------------------------------------------------- |
| `curl`                          | Works                                                                   |
| `openai-python`                 | Works. Extras through `extra_body={"cloudraker": {…}}`                  |
| `openai-node`                   | Works                                                                   |
| LiteLLM                         | Works. The `api_base` must include the `/v1` base above                 |
| LangChain `ChatOpenAI`          | Works. It reports 0-token calls, as documented                          |
| Continue, Cursor, model pickers | Work through `GET /models`. Use `paperwork-extract-cited` for citations |
| Vercel AI SDK                   | Works with `openai.chat(id)`. See below                                 |
| Browsers                        | Not supported. There are no CORS headers on this API                    |

### Vercel AI SDK

Call `openai.chat(id)`, not `openai(id)`. `openai(id)` targets OpenAI's
Responses API, which this façade does not implement.

```ts
import { createOpenAI } from '@ai-sdk/openai'
import { generateText } from 'ai'

const openai = createOpenAI({
  baseURL: 'https://api.paperwork.sh/v1',
  apiKey: process.env.CLOUDRAKER_API_KEY,
})

const { text } = await generateText({
  model: openai.chat('paperwork-parse'),
  messages: [{
    role: 'user',
    content: [{ type: 'file', data: new URL('https://example.com/invoice.pdf'), mediaType: 'application/pdf' }],
  }],
})
```

## What this API does not do

* No conversation state. Each request is one run.
* No tool calling, no `/responses`, no `/batches`, no embeddings.
* No token counts and no per-request cost.
* No browser (CORS) support.
* No [space](/paperwork/developers/spaces) scoping. Façade runs land in a hidden
  workspace, expire with their `ttl`, and are not indexed. Use the native
  endpoints to keep results.

## Where to go next

#### [Extract](/capabilities/extract)

Schemas, citations, batching, and everything the façade maps onto.

#### [Files](/developers/files)

Registration, presigned uploads, and file ids.

#### [Errors](/developers/errors)

The capability envelope this API deliberately replaces.

#### [MCP server](/developers/mcp)

The full API for agents, with tool calling.