OpenAI-compatible API

Point any OpenAI client at Paperwork. Parse, extract, and redact documents through /chat/completions.

View as Markdown

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

EnvironmentBase URL
Productionhttps://api.paperwork.sh/v1
Production (alternate)https://api.cloudraker.com/openai/v1
Developmenthttps://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 as the Bearer token. Nothing else changes.

$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:

1{
2 "error": {
3 "message": "Missing or malformed Authorization header. Send `Authorization: Bearer <api key>`.",
4 "type": "authentication_error",
5 "param": null,
6 "code": "invalid_api_key"
7 }
8}

Models

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

ModelCapabilityNotes
paperwork-parseParseMarkdown of the document
paperwork-extractExtractCitations off
paperwork-extract-citedExtractCitations on
paperwork-redactRedactDocuments or audio, routed by MIME type
paperwork-autoParse or extractresponse_format decides
paperworkParse or extractAlias 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:

1{
2 "error": {
3 "message": "Unknown model 'gpt-4o'. GET /openai/v1/models lists the available models.",
4 "type": "invalid_request_error",
5 "param": "model",
6 "code": "model_not_found"
7 }
8}

Your first call

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

$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:

1{
2 "id": "chatcmpl-par_01KYDQJBG32QDWHM5ERG63XGNE",
3 "object": "chat.completion",
4 "created": 1756500000,
5 "model": "paperwork-parse",
6 "choices": [
7 {
8 "index": 0,
9 "message": {
10 "role": "assistant",
11 "content": "# Invoice 4417\n\n| Item | Amount |\n| --- | --- |\n",
12 "refusal": null,
13 "annotations": []
14 },
15 "logprobs": null,
16 "finish_reason": "stop"
17 }
18 ],
19 "usage": {
20 "prompt_tokens": 0,
21 "completion_tokens": 0,
22 "total_tokens": 0,
23 "cloudraker": {}
24 },
25 "cloudraker": {
26 "runId": "par_01KYDQJBG32QDWHM5ERG63XGNE",
27 "object": "parse_run",
28 "statusUrl": "https://api.cloudraker.com/v1/runs/par_01KYDQJBG32QDWHM5ERG63XGNE"
29 },
30 "system_fingerprint": "fp_cloudraker"
31}

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.

Modelcontent
paperwork-parseThe markdown. Over the 1 MiB inline limit, a short text with the markdown and JSON URLs
paperwork-extractJSON.stringify of the extracted value. Several documents give an array
paperwork-redactJSON.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 partMeaning
{"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".

1{
2 "error": {
3 "message": "No input file. Attach a file part — `{\"type\":\"file\",\"file\":{\"file_id\":\"\"}}` or `{\"type\":\"image_url\",\"image_url\":{\"url\":\"https://…\"}}`.",
4 "type": "invalid_request_error",
5 "param": "messages",
6 "code": "missing_file"
7 }
8}

The Files API

The OpenAI file endpoints work, over the same corpus as /v1/files:

MethodPath
POST/filesmultipart/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.

1from openai import OpenAI
2
3client = OpenAI(
4 base_url="https://api.paperwork.sh/v1",
5 api_key="<your CloudRaker API key>",
6)
7
8uploaded = client.files.create(file=open("invoice.pdf", "rb"), purpose="assistants")
9
10completion = client.chat.completions.create(
11 model="paperwork-extract",
12 messages=[{
13 "role": "user",
14 "content": [
15 {"type": "text", "text": "Pull the totals and the due date."},
16 {"type": "file", "file": {"file_id": uploaded.id}},
17 ],
18 }],
19)
20print(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.

ModelWhere the text goes
paperwork-extract with a schemainstructions
paperwork-extract without a schemahints, which steer schema inference. Truncated to 2000 characters
paperwork-redactinstructions
paperwork-parseDropped. Parse takes no instructions

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

Structured output

response_formatEffect
{"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 absentpaperwork-auto parses instead of extracting
$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 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.

KeyApplies toWhat it does
citationsextractGround each field in the source
judgeextractRun 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
unitextractWhat one result covers: per_document, across_documents, or rows_per_document
actionextract, redactRun a saved action by slug or id
hintsextractSteer schema inference explicitly
categoriesredactThe PII categories to remove
moderedactDocument redaction mode: targeted or lines
styleredactAudio redaction style: beep or silence
waitallSeconds to hold the call open. 0 to 120
ttlallRun retention
webhookallNotify an endpoint when the run finishes
1completion = client.chat.completions.create(
2 model="paperwork-redact",
3 messages=[{"role": "user", "content": [
4 {"type": "file", "file": {"file_id": "file_01KYDQJBG32QDWHM5ERG63XGNE"}},
5 ]}],
6 extra_body={"cloudraker": {"mode": "targeted", "categories": ["person", "email"]}},
7)

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:

1{
2 "error": {
3 "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.",
4 "type": "server_error",
5 "param": null,
6 "code": "run_incomplete"
7 }
8}

The run keeps going. Read it at GET /v1/runs/{id}, 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:

1{ "error": { "message": "", "type": "", "param": null, "code": "" } }
HTTPtypecodeCause
400invalid_request_errorinvalid_requestMalformed body
400invalid_request_errorinvalid_schemaThe schema breaks the dialect
400invalid_request_errormissing_fileNo file part
400invalid_request_errortoo_many_filesOver the model’s file ceiling
400invalid_request_errorunsupported_content_partA content part we cannot read
400invalid_request_errorunsupported_parameterA parameter we refuse instead of ignoring
400invalid_request_errorcontext_length_exceededThe dispatch exceeds the size budget
401authentication_errorinvalid_api_keyMissing, malformed, or invalid key
402insufficient_quotacredits_exhaustedThe organization is out of credits
402insufficient_quotafeature_not_in_planThe plan does not include this capability
403permission_errorinsufficient_permissionsThe key lacks the permission
404invalid_request_errormodel_not_foundUnknown model
404invalid_request_errorunknown_urlUnknown path on this API
409invalid_request_errorrun_expiredAn idempotent replay of a purged run. Retry: it starts a new run
409invalid_request_errorfile_not_readyThe bytes are still settling
413invalid_request_errorrequest_too_largeBody over 25 MB
429rate_limit_errorrate_limit_exceededRate limited. Honor Retry-After
502server_errorrun_failedThe run ended without a usable result
504server_errorrun_incompleteThe wait cap expired. The run continues
503 / 500server_errorthe underlying codeTransient. 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.

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 — 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:

ParameterWhy
n other than 1One document, one result
tools, functions, tool_choice: "required"Use the MCP server for tool-driven work
logprobs, top_logprobsThere is no token stream
audioNo audio output
predictionNo predicted outputs
web_search_optionsNo web search
stream_options without stream: trueMatches OpenAI

Clients

ClientStatus
curlWorks
openai-pythonWorks. Extras through extra_body={"cloudraker": {…}}
openai-nodeWorks
LiteLLMWorks. The api_base must include the /v1 base above
LangChain ChatOpenAIWorks. It reports 0-token calls, as documented
Continue, Cursor, model pickersWork through GET /models. Use paperwork-extract-cited for citations
Vercel AI SDKWorks with openai.chat(id). See below
BrowsersNot 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.

1import { createOpenAI } from '@ai-sdk/openai'
2import { generateText } from 'ai'
3
4const openai = createOpenAI({
5 baseURL: 'https://api.paperwork.sh/v1',
6 apiKey: process.env.CLOUDRAKER_API_KEY,
7})
8
9const { text } = await generateText({
10 model: openai.chat('paperwork-parse'),
11 messages: [{
12 role: 'user',
13 content: [{ type: 'file', data: new URL('https://example.com/invoice.pdf'), mediaType: 'application/pdf' }],
14 }],
15})

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