DocuTray

Steps

Run and manage Docutray steps — preconfigured pipelines that chain conversion, identification, and validation, with multi-language SDK and REST examples.

Steps are preconfigured document processing pipelines. Each step defines a specific processing workflow — you provide a document and DocuTray executes the step's pipeline, returning structured results. Steps are always executed asynchronously.

You can also manage your organization's steps over the API — list, inspect, create, update, and delete them without going through support. See Managing Steps.

Quick Start

from pathlib import Path
from docutray import Client

client = Client(api_key="YOUR_API_KEY")

# Execute a step
status = client.steps.run_async(
    step_id="step_abc123",
    file=Path("document.pdf")
)

# Wait for completion
result = status.wait()

if result.is_success():
    print(result.data)

Response

# status is a StepExecutionStatus
print(status.execution_id)        # "exec_abc123"
print(status.status)              # "SUCCESS"
print(status.data)                # Extracted data
print(status.original_filename)   # "document.pdf"
print(status.request_timestamp)   # When execution started
print(status.response_timestamp)  # When execution completed

Polling and Status

Steps are always asynchronous. You can poll for status manually or use the SDK's built-in wait() method.

status = client.steps.run_async(
    step_id="step_abc123",
    file=Path("document.pdf")
)

# Wait with automatic polling
result = status.wait()

if result.is_success():
    print("Step completed successfully")
    print(result.data)
elif result.is_error():
    print(f"Step failed: {result.error}")

Manual Polling

status = client.steps.get_status("exec_abc123")

if status.is_success():
    print(status.data)
elif status.status == "ENQUEUED" or status.status == "PROCESSING":
    print("Still processing...")

Input Methods

Steps support the same input methods as Convert and Identify: file upload, URL, and base64.

# File upload
status = client.steps.run_async(
    step_id="step_abc123",
    file=Path("document.pdf")
)

# URL
status = client.steps.run_async(
    step_id="step_abc123",
    url="https://example.com/document.pdf"
)

# Base64
import base64
with open("document.pdf", "rb") as f:
    encoded = base64.b64encode(f.read()).decode()

status = client.steps.run_async(
    step_id="step_abc123",
    file_base64=encoded,
    content_type="application/pdf"
)

Parameters

ParameterTypeRequiredDescription
step_id / stepIdstringYesID of the step to execute
fileFileNoFile to process (path, bytes, or file object)
urlstringNoPublic URL of the document
file_base64 / base64stringNoBase64-encoded document content
content_typestringNoMIME type (auto-detected if not provided)
document_metadataobjectNoCustom metadata returned in status responses

You must provide exactly one of file, url, or file_base64/base64.

Complete Code

from pathlib import Path
from docutray import Client, NotFoundError, DocuTrayError

client = Client(api_key="YOUR_API_KEY")

try:
    # Execute a step with metadata
    status = client.steps.run_async(
        step_id="step_abc123",
        file=Path("invoice.pdf"),
        document_metadata={"source": "email", "customer_id": "cust_456"}
    )

    print(f"Execution started: {status.execution_id}")

    # Wait for completion
    result = status.wait()

    if result.is_success():
        print("Step completed!")
        print(f"Result: {result.data}")
    elif result.is_error():
        print(f"Step failed: {result.error}")

except NotFoundError:
    print("Step not found — check the step ID")
except DocuTrayError as e:
    print(f"Error: {e.message}")
finally:
    client.close()

Managing Steps

Besides executing steps, your organization can manage them over the API:

OperationEndpointRole required
List stepsGET /api/stepsany member
Get a stepGET /api/steps/{id}any member
Create a stepPOST /api/stepsowner or admin
Update a stepPATCH /api/steps/{id}owner or admin
Delete a stepDELETE /api/steps/{id}owner or admin

Every request is scoped to the organization the API key belongs to — never to the other organizations its owner may be a member of. A step from another organization is indistinguishable from one that does not exist: both return 404.

The Python SDK, the Node.js SDK, and the CLI cover step execution only. The management examples below use plain HTTP instead of inventing SDK methods that do not exist yet. They will be replaced with SDK calls once the clients ship management support.

Setup

Every management example below builds on this snippet. Run it first, or paste it above whichever example you are copying.

import requests

BASE_URL = "https://app.docutray.com"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
JSON_HEADERS = {**HEADERS, "Content-Type": "application/json"}

The Step Resource

FieldTypeWritableDescription
idstringnoStep identifier, used to execute the step
namestringyes1–255 characters, measured after trimming. Surrounding whitespace is stripped before storing, and a whitespace-only name is rejected. Required when creating
descriptionstring | nullyesUp to 1000 characters
isActivebooleanyesWhether the step can be executed. Defaults to true
identification.typesstring[]yesDocument type codes used for automatic identification. When automatic identification is on, an empty list means every document type the organization can access. A step configured with a fixed document type does no automatic identification and always reads as []. Codes whose document type was deleted are dropped from the response — including types deleted while still referenced elsewhere, which DocuTray keeps on file but treats as gone — so a step that only referenced deleted types also reads as []
executions30dintegernoExecutions started in the last 30 days
lastRunAtstring | nullnoISO 8601 start time of the most recent execution
createdAt / updatedAtstringnoISO 8601 timestamps
createdBystringnoID of the user who created the step

What the public contract does not expose. RAG configurations, the AI model (aiModelConfig), conversion options, and result caching are managed by DocuTray and are neither readable nor writable through this API. A step created here always gets the same internal processing configuration, and none of it changes what an execution returns. Result caching is not document deduplication: it stores each execution's own result so that Polling and Status can serve it without reading the database. Submitting the same document twice runs the whole pipeline twice. Webhooks are not part of the resource either: there is no per-step webhook URL, step execution events are delivered through your organization's webhook configuration — documented in Steps Events — and the per-step switch that mutes those events is administered by DocuTray, not readable or writable here. A step created through this API is never muted, so its events are delivered; if a step created in the admin panel stops emitting STEP_* events, that switch is where to look.

List Steps

Steps are returned newest first, paginated.

response = requests.get(
    f"{BASE_URL}/api/steps",
    headers=HEADERS,
    params={"search": "invoice", "page": 1, "limit": 20},
)
response.raise_for_status()
body = response.json()

for step in body["data"]:
    print(f"{step['id']}: {step['name']}{step['executions30d']} runs in 30d")

print(f"Total: {body['pagination']['total']}")
Query parameterTypeDefaultDescription
searchstringMatches step name or description, case-insensitive
pageinteger1Page number, starts at 1
limitinteger20Results per page, maximum 100

Response

{
  "data": [
    {
      "id": "step_abc123",
      "name": "Invoice intake",
      "description": "Identifies and extracts incoming invoices",
      "isActive": true,
      "identification": { "types": ["invoice", "credit_note"] },
      "executions30d": 412,
      "lastRunAt": "2024-01-15T10:30:00.000Z",
      "createdAt": "2023-11-02T08:12:00.000Z",
      "updatedAt": "2024-01-10T16:45:00.000Z",
      "createdBy": "user_xyz789"
    }
  ],
  "pagination": {
    "total": 1,
    "page": 1,
    "limit": 20
  }
}

Get a Step

response = requests.get(f"{BASE_URL}/api/steps/step_abc123", headers=HEADERS)
response.raise_for_status()

step = response.json()["data"]
print(step["name"], step["isActive"], step["identification"]["types"])

The response is a single step wrapped in data, with the same fields as a list entry.

Create a Step

Only name is required. identification.types takes document type codes — the stable codeType values you get from Document Types — and every code must be a published type your organization can access, either its own or a public one. Draft types are rejected whoever owns them — your own or a public one not yet published — so publish first. Duplicate codes are collapsed, so the stored list can be shorter than the one you sent.

response = requests.post(
    f"{BASE_URL}/api/steps",
    headers=JSON_HEADERS,
    json={
        "name": "Invoice intake",
        "description": "Identifies and extracts incoming invoices",
        "isActive": True,
        "identification": {"types": ["invoice", "credit_note"]},
    },
)

if response.status_code == 400:
    print(response.json())  # includes "inaccessibleTypes" when a code is not allowed
response.raise_for_status()

step = response.json()["data"]
print(f"Created {step['id']}")

A successful create returns 201 with the new step under data, with executions30d at 0 and lastRunAt at null.

Omit identification — or send an empty list — to let the step consider every document type your organization can access. If any code is not accessible, the request fails with 400, the response lists the offending codes under inaccessibleTypes, and nothing is created.

Fields the contract does not define are ignored rather than rejected: sending aiModelConfig, webhookUrl or any other internal setting still returns 201, and the setting is dropped. PATCH does the same — except that a body made up only of unrecognized fields is left with nothing to change and fails with 400.

Update a Step

PATCH is partial: fields you leave out keep their current value. At least one field must be present.

# Rename and restrict the step to a single document type
response = requests.patch(
    f"{BASE_URL}/api/steps/step_abc123",
    headers=JSON_HEADERS,
    json={
        "name": "Invoice intake (EU)",
        "identification": {"types": ["invoice"]},
    },
)
response.raise_for_status()

# Deactivate without touching anything else
requests.patch(
    f"{BASE_URL}/api/steps/step_abc123",
    headers=JSON_HEADERS,
    json={"isActive": False},
).raise_for_status()

A successful update returns 200 with the whole updated step under data, in the same shape GET returns — not an empty 204.

An update is all-or-nothing. identification is validated before anything is written, so a body that mixes it with scalar fields lands entirely or not at all: PATCH {"name": "...", "identification": {"types": []}} against a fixed-type step returns 409 and the rename is discarded along with it. If you want a rename to survive an identification change being rejected, send them as two requests.

identification.types replaces the whole list — it is not merged. To add a type, send the existing codes plus the new one. Sending a non-empty list also switches the step to automatic identification, replacing any fixed document type it was configured with.

Codes the step already has configured are always accepted, even if your organization can no longer access them — for example a public type that was later unpublished — so an accessibility check will never reject a code you just read back. Adding a code you cannot access is still rejected with 400. That is only about accessibility, though: re-sending identification unchanged is not safe in general, for the reasons below.

Do not echo identification back blindly. Send only the fields you actually want to change. Replaying the object returned by GET changes the step's configuration in three cases:

  • A step with a fixed document type reads as types: []. Sending that empty list back is refused with 409 rather than applied, so the fixed type survives.
  • A step whose automatic identification was turned off while keeping its type list reads as a non-empty list. Sending that back turns automatic identification on again, with a 200 — the 409 guard does not cover this case.
  • A step whose configured document types were all deleted also reads as types: [], but it does use automatic identification, so the 409 guard does not apply either. Sending that empty list back is accepted with a 200 and widens the step to every document type your organization can access.

Concurrency is not uniform across fields. A write that includes identification.types reads, merges, and writes the step's configuration under a version guard, retrying transparently; only sustained contention returns 409, and then you should re-read the step and re-apply your change. A write touching only name, description or isActive has no such guard: two concurrent updates both succeed and the last one wins. Serialize your own writes if that matters to you.

Delete a Step

response = requests.delete(f"{BASE_URL}/api/steps/step_abc123", headers=HEADERS)

if response.status_code == 409:
    print(response.json())  # executions still in flight
response.raise_for_status()

print(response.json()["data"])  # {"id": "step_abc123", "deleted": True}

Deleting a step also deletes its entire execution history, in cascade and unrecoverably. To stop a step from running while keeping its history, send PATCH {"isActive": false} instead. An inactive step still lists and reads normally, but executing it returns 404 — a deactivated step is indistinguishable from a missing one to POST /api/steps-async/{stepId}.

A successful delete returns 200 with {"id": "...", "deleted": true} under data — not an empty 204.

Deletion is rejected with 409 while the step has executions in flight — enqueued, in progress, or uploading. An upload that never completed stops blocking the deletion 15 minutes after it started, so a request that died mid-upload does not keep the step alive forever. The response normally reports how many under activeExecutions; if executions keep arriving faster than the delete can be applied, it returns 409 without that field. Either way: wait for the executions to finish, or deactivate the step first so no new ones arrive, and retry.

Errors

StatusWhen
400Invalid body or pagination parameters, or identification.types introduces a document type the organization cannot access — codes the step already had configured do not count (offending codes listed under inaccessibleTypes)
401Missing or invalid API key
403API key without an organization, insufficient organization role for a write, or a key whose owner is no longer a member of that organization — that one fails reads and writes, since revoking a membership does not revoke the keys created under it
404The step does not exist in this organization
409Empty identification.types on a step that does not use automatic identification, a concurrent write, or a delete blocked by executions in flight
500Internal server error

SDK Reference

For detailed class and method documentation:

On this page