Skip to content

Getting started with the API

APIFor integratorsVerified 8 Oct 2026

The Ferrith API lets your own systems do what a person does in the workspace, start a workflow, upload the documents it needs, follow the run as it happens, and collect the result, using an API key issued by a workspace admin. It also provides an OpenAI-compatible chat completion endpoint. API access is available on the Automation plan.

The API is a run surface. Creating and editing workflows and acting on review steps stay in the workspace, where every change is attributed to a signed-in person. See the workflows guide for authoring.

All examples on this page use the Ferrith cloud address, https://api.ferrith.ai.

What you need

  • A workspace on the Automation plan.
  • An API key, created by a workspace admin on the workspace's API keys page. The key is shown once, at creation and cannot be displayed again.
  • The key is sent as a bearer token on every call: Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY.

Keys and scopes

A key starts with fck_ and is about 48 characters long. The workspace's key list shows only the first 8 characters. A key carries a fixed set of scopes chosen at creation:

Scope What it unlocks
workflows:read List workflows, read runs and run history, follow a run's event stream, download a run's outputs and input documents
workflows:run Start a run; cancel a run this key started
workflow-inputs:write Upload a document for a workflow run, and poll its processing status
chat:completions The OpenAI-compatible chat endpoints (/v1/models, /v1/chat/completions)
document-templates:read List the drafting templates you may use and read their input schemas
documents:write Produce a document from a drafting template
documents:read Read and download the documents this key's user produced through the API

Most workflow integrations want workflows:read and workflows:run together (one starts the run, the other follows it), plus workflow-inputs:write if the workflow takes documents.

Three more things an admin can set on a key:

  • A workflow allowlist. A key can be restricted to named workflows; a call about any other workflow is refused with 403 workflow_not_in_allowlist. An integration that only triggers one workflow should hold a key listing only that workflow.
  • An expiry date. By default a new key expires after 365 days; calls after expiry are refused exactly like a revoked key.
  • A rate limit — see Limits.

Keys cannot be edited after creation, apart from placing restrictions on the workflow allow list. To change a key's scopes, or if a key may have leaked you should revoke it and issue a new one. Revocation takes effect immediately on every call.

The interactive reference

The complete reference, every endpoint, every request and response shape, with live "try it" calls against your own workspace:

https://api.ferrith.ai/docs

Click Authorize, paste your key, and then use "try it". Two things worth knowing before you paste a credential into any page:

Note

The reference page runs on the API's own address, so your key travels only to the API, never through this documentation site, and the page does not store it: closing the tab forgets the key. For experiments, use a key with only the scopes you need and a short expiry, and revoke it when you're done.

The machine-readable contract behind that page is at https://api.ferrith.ai/openapi/v1.json and this can be used by tools to create a typed client.

Your first call

List the workflows your key can start:

bash
curl -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" https://api.ferrith.ai/workflows
json
[
  {
    "id": "1c684e4bff9243249a55d1679cb4b9df",
    "name": "NDA drafter",
    "description": "Captures the deal facts, drafts a mutual NDA, runs a partner review.",
    "hasActive": true,
    "activeVersionId": "7d0a2f6c33f34cf3a9d51f4be0b2a761",
    "stepCount": 3,
    "createdAt": "2026-08-01T09:12:44Z",
    "updatedAt": "2026-08-20T16:03:12Z"
  }
]

(Abbreviated. The response carries a few more housekeeping fields and the interactive reference shows the full shape.) The list contains only workflows with a published version, filtered to your key's allowlist if it has one. The same call in Python:

python
import requests

API = "https://api.ferrith.ai"
KEY = "fck_EXAMPLE_REPLACE_WITH_YOUR_KEY"

r = requests.get(f"{API}/workflows", headers={"Authorization": f"Bearer {KEY}"})
r.raise_for_status()
for workflow in r.json():
    print(workflow["id"], "—", workflow["name"])

If the call returns 401 with {"error":"invalid_api_key"}, the key is wrong, revoked, or expired, see the three error shapes.

Start a run and follow it

Starting a run is asynchronous: you POST, get a 202 with a run id straight back, and then follow the run, either by streaming its events or by polling its state.

What goes in input depends on how the workflow begins. A workflow that starts with an input form takes a JSON object matching its input schema (GET /workflows/{id} returns the schema; a payload that doesn't match is refused with a 400 naming each failing field). A workflow that starts with an intake agent takes a plain string.

Note

intake agent workflows are not suited to API runs as they normally require interaction beyond the first message, for API initiated workflows you should build them with input form.

bash
curl -X POST https://api.ferrith.ai/workflows/runs \
  -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"workflowId": "1c684e4bff9243249a55d1679cb4b9df", "input": "Draft a mutual NDA for a small IT consultancy. English law, three-year term."}'
json
{
  "runId": "b1f6a6f0-08e4-4bc7-9d3e-6a4f5c2d9e01",
  "status": "Pending",
  "startedAt": "2026-08-25T10:14:03Z"
}

Follow it live with the run's Server-Sent Events stream (replace RUN_ID with the runId from the 202):

bash
curl -N -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  https://api.ferrith.ai/workflows/runs/{RUN_ID}/events
text
event: connected
data: {"runId":"b1f6a6f0-08e4-4bc7-9d3e-6a4f5c2d9e01","status":"Running"}

event: StepStarted
data: {"runId":"b1f6a6f0-08e4-4bc7-9d3e-6a4f5c2d9e01","stepId":"draft","stepIndex":1, …}

event: StepProgress
data: {"runId":"b1f6a6f0-08e4-4bc7-9d3e-6a4f5c2d9e01","stepId":"draft","delta":"This Agreement is made…"}

event: StepCompleted
data: {"runId":"b1f6a6f0-08e4-4bc7-9d3e-6a4f5c2d9e01","stepId":"draft","status":"Completed", …}

event: RunCompleted
data: {"runId":"b1f6a6f0-08e4-4bc7-9d3e-6a4f5c2d9e01","status":"Completed","completedAt":"2026-08-25T10:15:41Z", …}

The stream opens with a connected frame, then mirrors the run's progress events (StepStarted, StepProgress deltas as the model writes, StepCompleted, and the terminal RunCompleted / RunFailed / RunCancelled), and closes when the run finishes. Asking for the stream of an already-finished run returns 409 run_terminal, fetch the run's state instead:

bash
curl -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  https://api.ferrith.ai/workflows/runs/{RUN_ID}

That returns the full run, status, each step's state and output, token totals, and the download paths for anything the run rendered. GET /workflows/runs lists recent runs, and takes ?workflowId=, ?status=, ?startedFrom= and ?startedTo= filters that search history rather than just the latest page.

Two rules about what a key can see and do:

  • A key reads API-started runs only. Runs people start in the workspace are never visible through the API, whichever key is used. An allowlisted key sees only runs of its allowlisted workflows.
  • A key cancels only runs it started itself: POST /workflows/runs/{RUN_ID}/cancel.

If a run pauses at a review step, it waits for a reviewer to act in the workspace and the run's status shows the pause, and the stream resumes when the reviewer approves. Review decisions cannot be made through the API.

Documents in a run

A workflow whose input form includes a document field takes its documents by upload first, then reference. Uploading needs the workflow-inputs:write scope.

Upload file directly:

bash
curl -X POST https://api.ferrith.ai/workflow-inputs/documents \
  -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  -F "file=@contract.pdf"
json
{
  "documentId": "93560144-0ef5-46fb-9444-cb97e2c52393",
  "documentVersionId": "4d19a408-39e1-453e-953b-192b6a19540d",
  "filename": "contract.pdf",
  "sizeBytes": 482133,
  "contentType": "application/pdf",
  "pageCount": null,
  "status": "processing"
}

The response is a 202 meaning the document is stored, and processing (text extraction, indexing) continues in the background. The Location header points at the status endpoint; poll it every few seconds until status is "ready":

bash
curl -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  https://api.ferrith.ai/workflow-inputs/documents/{DOCUMENT_ID}

A typical contract is ready in seconds to a couple of minutes, depending on size. A "failed" status is terminal for that upload (the processingError field carries a short reason) and you should try again. Starting a run against a document that isn't ready yet is refused with 400 document_not_ready.

If you'd rather send JSON than multipart, POST the same endpoint with { "filename": "contract.pdf", "contentType": "application/pdf", "data": "<base64>" }.

bash
curl -X POST https://api.ferrith.ai/workflow-inputs/documents \
  -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filename":"contract.pdf", "contentType":"application/pdf", "data":"JVBERi0xLjYNJeL........."}'

Reference the upload in the run's input as an object carrying both ids a single id is refused with 400 document_field_invalid_format. As an example, where the input schema to a workflow is contract (document) and conterpartName (string):

json
{
  "workflowId": "1c684e4bff9243249a55d1679cb4b9df",
  "input": {
    "contract": {
      "documentId": "93560144-0ef5-46fb-9444-cb97e2c52393",
      "documentVersionId": "4d19a408-39e1-453e-953b-192b6a19540d"
    },
    "counterpartyName": "Example Consulting Ltd"
  }
}

A field that takes several documents takes an array of the same objects. Leave an optional document field out entirely rather than sending null.

Uploads can be refused at request time with a 400 naming the reason: unsupported_media_type (the API takes PDF, DOCX, Markdown and plain text), size_exceeded (over the deployment's size limit), too_many_pages (a PDF over the page limit, 300 pages by default, split the document), or storage_limit_reached (the workspace is at its storage limit; the body carries the usage and the limit, free up space or add storage, then retry).

Once a run has used a document, you can fetch it back with GET /workflows/runs/{RUN_ID}/input-documents/{DOCUMENT_VERSION_ID}/download — the version id must be one that run actually used.

Rendered outputs

A workflow that ends by rendering a document (PDF or DOCX) records the artifact on the run. The run detail's renderedArtifact block carries a ready-made downloadPath which can be resolved against the API address rather than building the URL by hand:

bash
curl -OJ -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  "https://api.ferrith.ai/workflows/runs/{RUN_ID}/files/{STEP_ID}/download"

The response streams the file with its content type and filename. 404 means the run, step, or artifact doesn't exist.

Prefer JSON? Add ?encoding=base64 and the same call answers 200 application/json with the file's details and its bytes base64-encoded — the upload's JSON shape, in reverse — so a system that only speaks JSON can take a rendered document without a binary code path:

json
{
  "filename": "engagement-letter.docx",
  "contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "format": "docx",
  "sizeBytes": 48211,
  "data": "UEsDBBQABgAI…"
}

encoding=binary (or no parameter) is the bytes; any other value is 400 invalid_encoding. The same parameter works on a template document's content (below).

Document templates

A workspace's document authors can fix a template in the drafting library — a document type and the blocks a document always starts with. With the template scopes, an integration produces the same document the app would, from a JSON input, in one call:

  1. List the templates you may use, then read one for its inputSchema — a JSON Schema describing the answers the document needs. A field with a workspace default carries default (leave it out to take the default); a fixed one carries readOnly: true as well — never post it. An image field is a string with contentEncoding: base64.
bash
curl -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  "https://api.ferrith.ai/document-templates/{TEMPLATE_ID}"
  1. Produce the document. Post the answers as a nested JSON document matching the schema. The call is synchronous — the answers are validated, the document assembled and its DOCX and PDF rendered before the response — and no model is involved, so nothing counts against your model usage.
bash
curl -X POST -H "Authorization: Bearer fck_EXAMPLE_REPLACE_WITH_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input": {"client": {"full_name": "Ann Example"}}, "title": "Engagement letter — Ann Example", "strict": true}' \
  "https://api.ferrith.ai/document-templates/{TEMPLATE_ID}/documents"

The 201 response carries status — assembled when every answer was given or defaulted, incomplete when a required answer was missing (named in unresolvedFields; with "strict": true that is a 422 fields_missing instead and nothing is produced) — plus links to the record and to the content in each format. A value of the wrong type, one outside its options, or an unknown key is 400 input_validation_failed with an errors array naming the path; a fixed field posted is 400 field_fixed; a request body over 10 MB is 413.

Dates are yyyy-MM-dd. A date-time is accepted as well, and its date is taken as written, whatever the time zone. A date with the month written as a word, such as 7 October 2026, is read as written too. Any other numeric form is refused with 400 input_validation_failed, because a date like 07/10/2026 is 7 October in some systems and 10 July in others: convert your system's dates before you post them.

Pictures go in the input as base64 strings, with or without a data: URI prefix: PNG or JPEG, up to 1 MB and 2,000 pixels on each side.

json
{
  "input": {
    "employee": {
      "full_name": "Sam Example",
      "photo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…"
    }
  },
  "strict": true
}

Leave a picture out and the field's workspace picture prints, if it has one. A picture that isn't PNG or JPEG, is over the limits or isn't valid base64 is 400 input_validation_failed, with the field's path in errors. A picture the workspace sets, such as a letterhead logo on a fixed field, can't be replaced: posting one is 400 field_fixed.

  1. Download the content from the link, as docx, pdf or markdown, as bytes or with ?encoding=base64 as JSON. If the template or its document type requires a review, the download answers 403 review_required until someone in the workspace has reviewed the document in the app.

The document belongs to the key's user. In the app it's listed on the drafts page's API tab, which workspace admins see, and it can be reviewed, shared and exported like any other. A key reads back only the documents its user produced through the API; a document made in the app is not visible to it.

Use the OpenAI SDK

The API also provides an OpenAI chat endpoint, so tools and SDKs that already talk to OpenAI-compatible servers work by pointing them at https://api.ferrith.ai/v1 with your key (scope chat:completions):

python
from openai import OpenAI

client = OpenAI(
    base_url="https://api.ferrith.ai/v1",
    api_key="fck_EXAMPLE_REPLACE_WITH_YOUR_KEY",
)

reply = client.chat.completions.create(
    model="gpt-oss-120b",
    messages=[{"role": "user", "content": "Summarise the key obligations in a mutual NDA."}],
    max_completion_tokens=2048,
)
print(reply.choices[0].message.content)

GET /v1/models lists the chat models your workspace can use and you can pick the model value from there. Streaming works the OpenAI way (stream: true).

Three things to know:

  • The endpoint is stateless. Nothing is stored, and nothing appears in anyone's chat history so you must send the full message array on every turn, exactly as the OpenAI protocol expects.
  • Tool calling is not supported yet a request with tools is refused with 400 tools_not_supported_yet.
Note

On a reasoning model, max_tokens / max_completion_tokens caps the total completion, the model's internal reasoning included. A small cap can be spent on reasoning before any answer appears, returning a near-empty completion rather than an error. Budget a couple of thousand tokens unless you have measured smaller.

Limits

Each key is rate-limited 300 requests per minute by default (the workspace's default; an admin can set a different limit per key at creation). Over the limit, calls return 429 with a Retry-After header and an error body of type rate_limited.

Separately, the workspace's own model-usage quotas apply to what the API spends, exactly as they do in the app. A quota refusal has type quota_exceeded and carries reset_at.

The three error shapes

Errors come in three shapes, depending on which layer refused the call. Branch on the status code first, then the shape:

1. Authentication (401, 423, 503) a bare envelope, plus a WWW-Authenticate header naming the same code:

json
{"error": "invalid_api_key"}

invalid_api_key (401) covers wrong, revoked and expired keys. tenant_locked (423) means the workspace's encryption keys are momentarily unavailable; retry with backoff. feature_disabled (503) means API keys are switched off on the deployment.

2. Permission and request refusals (400, 403, 404) a code and a human-readable message, sometimes with extra fields:

json
{"error": "missing_scope", "message": "This API key does not carry a required scope."}

This is the shape of missing_scope, workflow_not_in_allowlist, document_not_ready, the upload refusals, the input-validation refusals (which add a details array naming each failing field), and the template refusals (input_validation_failed adds an errors array with each failing field's path; fields_missing is a 422 naming the unanswered fields in unresolvedFields). The codes are stable, branch on error, and then use message for details.

3. Inference errors (429 and upstream failures) the OpenAI-shaped nested envelope, on chat completions and on model calls made inside a run:

json
{
  "error": {
    "type": "rate_limited",
    "message": "Rate limit exceeded. Retry after 12 seconds.",
    "scope": "user",
    "metric": "requests",
    "limit": 300,
    "window": "minute",
    "reset_at": "2026-08-25T10:15:00Z",
    "retry_after_seconds": 12,
    "request_id": "0a1b2c3d4e5f"
  }
}

Branch on error.type: rate_limited → wait retry_after_seconds and retry; quota_exceeded → wait until reset_at; anything else (no_healthy_instance, upstream_error, model_access_denied) → surface the message, and quote the request_id if you contact support as it lets us find the exact call without you sharing any content.