# OpenSOP API Reference

> Getting started — define a process, get an API. Agents and humans interact through the same `/sop/*` endpoints.

This document is the full machine-readable mirror of the OpenSOP API reference at https://coba.opensop.ai/api-docs.

## Contents

- [Quickstart](#guide-quickstart)
- [Authentication](#guide-authentication)
- [Process format](#guide-process-format)
- [Errors](#guide-errors)
- [Versioning](#guide-versioning)


**Discovery**
- [`GET /sop/`](#endpoint-list-processes) — List all processes
- [`GET /sop/:name/schema`](#endpoint-process-schema) — Get process schema

**Processes**
- [`POST /sop/processes/register`](#endpoint-register-process) — Register a process

**Instances**
- [`GET /sop/instances`](#endpoint-list-instances) — List all instances
- [`POST /sop/:name/start`](#endpoint-start-instance) — Start an instance
- [`GET /sop/:name/:id`](#endpoint-show-instance) — Get instance state
- [`POST /sop/:name/:id/cancel`](#endpoint-cancel-instance) — Cancel an instance

**Steps**
- [`GET /sop/:name/:id/steps`](#endpoint-list-steps) — List steps
- [`POST /sop/:name/:id/steps/:step_id/submit`](#endpoint-submit-step) — Submit a step
- [`GET /sop/steps/pending`](#endpoint-pending-steps) — List pending steps

**Webhook triggers**
- [`POST /sop/triggers/:process_name`](#endpoint-fire-trigger) — Fire a trigger

**Webhook callbacks**
- [`POST /sop/webhooks/:callback_id`](#endpoint-deliver-callback) — Deliver a webhook callback

**Metrics**
- [`GET /sop/metrics`](#endpoint-show-metrics) — Get metrics

---

# Guides


<a id="guide-quickstart"></a>

## Quickstart

From zero to a running process instance in under five minutes.

### What you'll build

A trivial process called `hello-world` that takes one input and returns one output. You'll **register** it from YAML, **start** an instance via the API, and read the result back.

> **Prereq:** An OpenSOP engine reachable at `https://api.opensop.dev` and a workspace token. Set `export OPENSOP_TOKEN=sk_…` before running the snippets below.

### 1. Write the YAML

Save this as `hello-world.yaml`:

```yaml
opensop: "0.1"
process:
  name: hello-world
  version: "1.0"
  description: A trivial process
  inputs:
    - { name: who, type: string, required: true }
  outputs:
    - { name: greeting, type: string }
  steps:
    - id: greet
      type: automated
      run: ./greet.py
      outputs:
        - { name: greeting, value: "Hello, ${inputs.who}!" }
```

### 2. Register the process

Upload the YAML via multipart POST:

#### curl

```bash
curl https://api.opensop.dev/sop/processes/register \
  -X POST \
  -H "X-SOP-Token: $OPENSOP_TOKEN" \
  -F "file=@hello-world.yaml"
```

#### Node

```js
const form = new FormData();
form.append("file", fs.createReadStream("./hello-world.yaml"));
await fetch("https://api.opensop.dev/sop/processes/register", {
  method: "POST",
  headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN },
  body: form
});
```

### 3. Start an instance

POST to start with inputs:

#### curl

```bash
curl https://api.opensop.dev/sop/hello-world/start \
  -X POST \
  -H "X-SOP-Token: $OPENSOP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"inputs":{"who":"world"}}'
```

#### Node

```js
const res = await fetch("https://api.opensop.dev/sop/hello-world/start", {
  method: "POST",
  headers: {
    "X-SOP-Token": process.env.OPENSOP_TOKEN,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ inputs: { who: "world" } })
});
const instance = await res.json();
```

### 4. Poll for completion

Read `state` from `GET /sop/hello-world/:id` until it is `completed` or `failed`.

#### curl

```bash
curl https://api.opensop.dev/sop/hello-world/:id \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
// Poll until state is "completed" or "failed"
const check = async (id) => {
  const r = await fetch(
    `https://api.opensop.dev/sop/hello-world/${id}`,
    { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
  );
  return r.json();
};
```

### Lifecycle

Instance states progress in this order:

`pending` → `running` → `waiting` → `running` → `completed`

On any error: `running` → `failed`

Cancelled instances move directly to `cancelled` from any non-terminal state.

### Next steps

- Add a `judgment` step with `allow_agent: true` to let an LLM decide.
- Add a `webhook` step to wait on a third party (Stripe, DocuSign, internal service).
- Replace polling with an outbound webhook on state_changed.


---

<a id="guide-authentication"></a>

## Authentication

Three auth modes, one for each direction traffic flows.

### 1. Outbound calls — bearer token

Every request to the OpenSOP API except `/sop/triggers/*` and `/sop/webhooks/*` requires an `X-SOP-Token` header. Tokens are workspace-scoped.

```bash
curl https://api.opensop.dev/sop/ \
  -H "X-SOP-Token: sk_workspace_acme_4f9c..."
```

### 2. Inbound triggers — HMAC

Endpoints under `/sop/triggers/:process_name` have no bearer token. Instead, the third party signs the raw request body with a shared secret. The engine looks up the secret declared at `process.trigger.auth.secret_env`, recomputes the signature, and compares constant-time.

> **Important:** Provider signature schemes vary — different headers, encodings (hex / base64), and prefixes. See the provider matrix on the trigger endpoint page.

### 3. Webhook callbacks — single-use ID

When a webhook step starts, the engine generates a callback URL containing a one-shot ULID. The third party POSTs back to that URL. The ID itself is the credential — once consumed, it is invalidated.

Callback URLs expire after the step's `timeout`, default 7 days.

### Token rotation

| Action | Endpoint |
|--------|----------|
| Create token | `POST /workspace/tokens` |
| List active tokens | `GET /workspace/tokens` |
| Revoke token | `DELETE /workspace/tokens/:id` |


---

<a id="guide-process-format"></a>

## Process format

The YAML schema every process is written in. Stable from opensop: &quot;0.1&quot; onward.

### Anatomy

```yaml
opensop: "0.1"
process:
  name: customer-onboarding
  version: "1.0"
  owner: banking-team
  description: Onboard a new business customer
  trigger:
    type: api          # api | schedule | webhook
  inputs:
    - { name: company_name, type: string, required: true }
    - { name: country, type: enum, values: [US, MX], required: true }
  outputs:
    - { name: account_id, type: string }
    - { name: status, type: enum, values: [approved, rejected] }
  steps:
    - id: collect-business-info
      name: Collect business information
      type: form          # form | automated | judgment | webhook
      timeout: 7d
    - id: review-application
      name: Review application
      type: judgment
      judgment:
        allow_agent: true
        confidence_threshold: 0.8
        escalate_to: human
```

### Step types

| Type | Resolved by | Notes |
|------|-------------|-------|
| `form` | human | Collects structured inputs. Use for kickoff and HITL. |
| `automated` | worker | An external worker polls /steps/pending and submits outputs. |
| `judgment` | agent or human | If allow_agent: true, an LLM decides. Below the confidence threshold, escalates. |
| `webhook` | third party | Engine creates a callback URL; the step waits until something POSTs to it. |

### References

Anywhere a value is expected, you can use the `${...}` syntax to pull from upstream context:

```yaml
# Pull a prior step's output into the next step's input
inputs:
  applicant_name: "${steps.collect-business-info.outputs.company_name}"

# Map a webhook trigger payload field to a process input
inputs:
  company_name: "${trigger.payload.data.company}"
```


---

<a id="guide-errors"></a>

## Errors

Every error is JSON with a stable code field — safe to switch on.

### Shape

Every error response uses this JSON envelope:

```json
{
  "error":   "invalid_inputs",
  "details": [
    {
      "field":    "country",
      "code":     "not_in_enum",
      "expected": ["US", "MX"]
    }
  ]
}
```

### Status codes

| Status | Code | Meaning |
|--------|------|---------|
| 400 | `invalid_payload` | Body could not be parsed as JSON. |
| 401 | `unauthorized` | Token missing, malformed, or revoked. |
| 401 | `invalid_signature` | HMAC verification failed for a trigger. |
| 404 | `not_found` | Process, instance, or callback does not exist. |
| 409 | `callback_already_resolved` | One-shot webhook callback was already received. |
| 422 | `invalid_inputs` | Inputs failed schema validation. `details` lists each violation. |
| 422 | `invalid_definition` | Process YAML failed schema or syntax check. |
| 422 | `invalid_transition` | Tried to act on a terminal instance/step. |
| 429 | `rate_limited` | Too many requests. `Retry-After` header advises a wait. |
| 500 | `trigger_misconfigured` | Engine env var for HMAC secret is unset. |

### Retries

The engine itself retries failed `automated` steps with exponential backoff (max 3 attempts by default). The API does not retry your inbound calls — wrap idempotent calls (`POST /start`, `POST /submit`) yourself.


---

<a id="guide-versioning"></a>

## Versioning

Two version axes — the API itself, and individual processes — evolve independently.

### API version

The wire format is identified by the top-level `opensop:` field on every process YAML. The engine refuses to register YAML whose major version it does not understand.

| Version | Status | Notes |
|---------|--------|-------|
| 0.1 | CURRENT | Initial public release. |
| 0.2 | PREVIEW | Configurable metrics window, parallel step blocks. |

### Process versions

Every process has its own semver-style `version` string. Re-publishing under a new version does not affect in-flight instances — they continue running against the version they started under.

When you call `GET /sop/:name/schema` without `?version=`, you get the latest published version. To pin, pass it explicitly.


---

# Endpoints


## Discovery


<a id="endpoint-list-processes"></a>

## `GET /sop/`

**List all processes** — Returns every published process definition in the workspace. Use this for discovery — agents call this endpoint to learn what processes are available before starting an instance.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Response

`200 application/json` — Returns an array of process definition objects.

```json
[
  {
    "name":        "customer-onboarding",
    "version":     "1.0",
    "description": "Onboard a new business customer",
    "owner":       "banking-team"
  }
]
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 401 | `unauthorized` | Token missing or revoked. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/ \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const res = await fetch("https://api.opensop.dev/sop/", {
  headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN }
});
const processes = await res.json();
```

#### Python

```python
import requests

resp = requests.get(
  "https://api.opensop.dev/sop/",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
print(resp.json())
```

#### Ruby

```ruby
require "net/http"

uri = URI("https://api.opensop.dev/sop/")
req = Net::HTTP::Get.new(uri)
req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
puts Net::HTTP.start(uri.host, use_ssl: true) { |h| h.request(req).body }
```


---

<a id="endpoint-process-schema"></a>

## `GET /sop/:name/schema`

**Get process schema** — Returns the full YAML-parsed definition of a process. Useful for inspecting inputs, outputs, and steps before starting an instance. Defaults to the latest published version.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `name` (string, required) — The process name as defined in the YAML `process.name` field.

### Query parameters

- `version` (string, optional) — Semver version string. Omit to get the latest published version.

### Response

`200 application/json` — Full process definition object.

```json
{
  "name":        "customer-onboarding",
  "version":     "1.0",
  "description": "Onboard a new business customer",
  "inputs":      [...],
  "steps":       [...]
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 404 | `not_found` | No process with that name (or version) exists. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/customer-onboarding/schema \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const res = await fetch(
  "https://api.opensop.dev/sop/customer-onboarding/schema",
  { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
);
```

#### Python

```python
resp = requests.get(
  "https://api.opensop.dev/sop/customer-onboarding/schema",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Get.new("/sop/customer-onboarding/schema")
  req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
  puts h.request(req).body
end
```


---

## Processes


<a id="endpoint-register-process"></a>

## `POST /sop/processes/register`

**Register a process** — Upload a .sop.yaml file to register or update a process definition. If a process with the same name already exists, a new version is published. In-flight instances are unaffected.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Request body

**Content-Type:** `multipart/form-data`

- `file` (file, required) — The `.sop.yaml` file to register.

### Response

`201 application/json` — The registered process definition.

```json
{
  "name":    "customer-onboarding",
  "version": "1.0"
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 422 | `invalid_definition` | YAML failed schema or syntax check. |
| 401 | `unauthorized` | Token missing or revoked. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/processes/register \
  -X POST \
  -H "X-SOP-Token: $OPENSOP_TOKEN" \
  -F "file=@customer-onboarding.sop.yaml"
```

#### Node

```js
const form = new FormData();
form.append("file", fs.createReadStream("./customer-onboarding.sop.yaml"));
await fetch("https://api.opensop.dev/sop/processes/register", {
  method: "POST",
  headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN },
  body: form
});
```

#### Python

```python
with open("customer-onboarding.sop.yaml", "rb") as f:
  resp = requests.post(
    "https://api.opensop.dev/sop/processes/register",
    headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]},
    files={"file": f}
  )
```

#### Ruby

```ruby
uri = URI("https://api.opensop.dev/sop/processes/register")
req = Net::HTTP::Post.new(uri)
req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
req.set_form([["file", File.open("./customer-onboarding.sop.yaml")]])
```


---

## Instances


<a id="endpoint-list-instances"></a>

## `GET /sop/instances`

**List all instances** — Returns a paginated list of instances across all processes. Filter by process name or state to narrow results.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Query parameters

- `process` (string, optional) — Filter to a specific process name.
- `state` (enum, optional) — One of: `pending`, `running`, `completed`, `failed`, `cancelled`.

### Response

`200 application/json` — Paginated list of instance objects.

```json
[
  {
    "id":         "01HXYZ...",
    "process":    "customer-onboarding",
    "state":      "running",
    "started_at": "2024-01-15T10:30:00Z"
  }
]
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 401 | `unauthorized` | Token missing or revoked. |

### Code examples

#### curl

```bash
curl "https://api.opensop.dev/sop/instances?state=running" \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const res = await fetch(
  "https://api.opensop.dev/sop/instances?state=running",
  { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
);
```

#### Python

```python
resp = requests.get(
  "https://api.opensop.dev/sop/instances",
  params={"state": "running"},
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Get.new("/sop/instances?state=running")
  req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
  puts h.request(req).body
end
```


---

<a id="endpoint-start-instance"></a>

## `POST /sop/:name/start`

**Start an instance** — Creates a new instance of the named process and begins execution. Pass all required inputs in the JSON body. The engine validates inputs against the process schema and returns `422` if any required field is missing or fails validation.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `name` (string, required) — The process name. Must match an existing published process exactly.

### Request body

**Content-Type:** `application/json`

```json
{
  "inputs": {
    "company_name": "Acme Corp",
    "country":       "US"
  }
}
```

- `inputs` (object, required) — Key-value map of process inputs. All fields declared as `required: true` in the process definition must be present.

### Response

`201 application/json` — New instance object with `id` and initial `state`.

```json
{
  "id":         "01HXYZ_ACME_001",
  "process":    "customer-onboarding",
  "version":    "1.0",
  "state":      "running",
  "started_at": "2024-01-15T10:30:00Z"
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 404 | `not_found` | No published process with that name. |
| 422 | `invalid_inputs` | One or more inputs failed schema validation. |
| 401 | `unauthorized` | Token missing or revoked. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/customer-onboarding/start \
  -X POST \
  -H "X-SOP-Token: $OPENSOP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"inputs":{"company_name":"Acme Corp","country":"US"}}'
```

#### Node

```js
const res = await fetch(
  "https://api.opensop.dev/sop/customer-onboarding/start",
  {
    method: "POST",
    headers: {
      "X-SOP-Token": process.env.OPENSOP_TOKEN,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      inputs: { company_name: "Acme Corp", country: "US" }
    })
  }
);
const instance = await res.json();
```

#### Python

```python
resp = requests.post(
  "https://api.opensop.dev/sop/customer-onboarding/start",
  json={
    "inputs": {
      "company_name": "Acme Corp",
      "country": "US"
    }
  },
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
print(resp.json())
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Post.new("/sop/customer-onboarding/start")
  req["X-SOP-Token"]   = ENV["OPENSOP_TOKEN"]
  req["Content-Type"] = "application/json"
  req.body = { inputs: { company_name: "Acme Corp", country: "US" } }.to_json
  puts h.request(req).body
end
```


---

<a id="endpoint-show-instance"></a>

## `GET /sop/:name/:id`

**Get instance state** — Returns the current state of a process instance — inputs, outputs, current state, and a summary of each step&#39;s status.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `name` (string, required) — The process name.
- `id` (string, required) — The instance ULID returned by `POST /start`.

### Response

`200 application/json` — Instance state with inputs, outputs, and step summary.

```json
{
  "id":           "01HXYZ_ACME_001",
  "process":      "customer-onboarding",
  "state":        "completed",
  "inputs":       { "company_name": "Acme Corp", "country": "US" },
  "outputs":      { "account_id": "ACC-9271", "status": "approved" },
  "started_at":   "2024-01-15T10:30:00Z",
  "completed_at": "2024-01-15T11:02:14Z"
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 404 | `not_found` | No instance with that id under the named process. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/customer-onboarding/01HXYZ_ACME_001 \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const res = await fetch(
  `https://api.opensop.dev/sop/customer-onboarding/${instanceId}`,
  { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
);
```

#### Python

```python
resp = requests.get(
  f"https://api.opensop.dev/sop/customer-onboarding/{instance_id}",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Get.new("/sop/customer-onboarding/#{instance_id}")
  req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
  puts h.request(req).body
end
```


---

<a id="endpoint-cancel-instance"></a>

## `POST /sop/:name/:id/cancel`

**Cancel an instance** — Terminates a running instance immediately. The instance moves to the `cancelled` state. Any pending steps are skipped. This action is irreversible.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `name` (string, required) — The process name.
- `id` (string, required) — The instance ULID.

### Response

`200 application/json` — Instance in `cancelled` state.

```json
{
  "id":    "01HXYZ_ACME_001",
  "state": "cancelled"
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 404 | `not_found` | Instance does not exist. |
| 422 | `invalid_transition` | Instance is already in a terminal state. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/customer-onboarding/01HXYZ_ACME_001/cancel \
  -X POST \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
await fetch(
  `https://api.opensop.dev/sop/customer-onboarding/${id}/cancel`,
  {
    method: "POST",
    headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN }
  }
);
```

#### Python

```python
requests.post(
  f"https://api.opensop.dev/sop/customer-onboarding/{instance_id}/cancel",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
req = Net::HTTP::Post.new("/sop/customer-onboarding/#{id}/cancel")
req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
Net::HTTP.start("api.opensop.dev", use_ssl: true) { |h| h.request(req) }
```


---

## Steps


<a id="endpoint-list-steps"></a>

## `GET /sop/:name/:id/steps`

**List steps** — Returns the state of every step in a process instance. Useful for tracking progress, debugging failures, and building status UIs.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `name` (string, required) — The process name.
- `id` (string, required) — The instance ULID.

### Response

`200 application/json` — Array of step state objects.

```json
[
  {
    "id":      "collect-business-info",
    "type":    "form",
    "state":   "completed",
    "outputs": { "company_name": "Acme Corp" }
  },
  {
    "id":    "review-application",
    "type":  "judgment",
    "state": "active"
  }
]
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 404 | `not_found` | Instance not found. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/customer-onboarding/01HXYZ_ACME_001/steps \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const steps = await fetch(
  `https://api.opensop.dev/sop/customer-onboarding/${id}/steps`,
  { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
).then(r => r.json());
```

#### Python

```python
resp = requests.get(
  f"https://api.opensop.dev/sop/customer-onboarding/{id}/steps",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Get.new("/sop/customer-onboarding/#{id}/steps")
  req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
  puts h.request(req).body
end
```


---

<a id="endpoint-submit-step"></a>

## `POST /sop/:name/:id/steps/:step_id/submit`

**Submit a step** — Advances a step by providing its outputs. The step must be in the `active` state. Submitting a terminal step or a step in the wrong state returns `422 invalid_transition`.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `name` (string, required) — The process name.
- `id` (string, required) — The instance ULID.
- `step_id` (string, required) — The step `id` as declared in the process YAML.

### Request body

**Content-Type:** `application/json`

- `outputs` (object, required) — Key-value map of step outputs as declared in the process YAML.

### Response

`200 application/json` — Updated step object.

```json
{
  "id":      "review-application",
  "state":   "completed",
  "outputs": { "decision": "approved" }
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 422 | `invalid_transition` | Step is not in the active state. |
| 404 | `not_found` | Instance or step not found. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/customer-onboarding/01HXYZ_ACME_001/steps/review-application/submit \
  -X POST \
  -H "X-SOP-Token: $OPENSOP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"outputs":{"decision":"approved","reason":"Documents verified"}}'
```

#### Node

```js
await fetch(
  `https://api.opensop.dev/sop/customer-onboarding/${id}/steps/review-application/submit`,
  {
    method: "POST",
    headers: {
      "X-SOP-Token": process.env.OPENSOP_TOKEN,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ outputs: { decision: "approved" } })
  }
);
```

#### Python

```python
requests.post(
  f"https://api.opensop.dev/sop/customer-onboarding/{id}/steps/review-application/submit",
  json={"outputs": {"decision": "approved"}},
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
req = Net::HTTP::Post.new("/sop/customer-onboarding/#{id}/steps/review-application/submit")
req["X-SOP-Token"]   = ENV["OPENSOP_TOKEN"]
req["Content-Type"] = "application/json"
req.body = { outputs: { decision: "approved" } }.to_json
Net::HTTP.start("api.opensop.dev", use_ssl: true) { |h| h.request(req) }
```


---

<a id="endpoint-pending-steps"></a>

## `GET /sop/steps/pending`

**List pending steps** — Returns all steps across all instances that are in the `active` state and of type `automated`. External workers poll this endpoint to discover work.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Response

`200 application/json` — Array of active automated step objects with their instance context.

```json
[
  {
    "step_id":     "send-welcome-email",
    "instance_id": "01HXYZ_ACME_001",
    "process":     "customer-onboarding",
    "inputs":      { "email": "admin@acmecorp.com" }
  }
]
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 401 | `unauthorized` | Token missing or revoked. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/steps/pending \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const pending = await fetch(
  "https://api.opensop.dev/sop/steps/pending",
  { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
).then(r => r.json());
```

#### Python

```python
resp = requests.get(
  "https://api.opensop.dev/sop/steps/pending",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Get.new("/sop/steps/pending")
  req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
  puts h.request(req).body
end
```


---

## Webhook triggers


<a id="endpoint-fire-trigger"></a>

## `POST /sop/triggers/:process_name`

**Fire a trigger** — Starts a new process instance via an inbound webhook trigger. No bearer token required — auth is HMAC-based. The raw request body is signed by the third party using the shared secret declared in the process definition.

> **Auth:** No bearer token. Auth is HMAC — the third party signs the raw request body using the shared secret declared at `process.trigger.auth.secret_env`. See [Authentication](/api-docs/guides/authentication.md).

### Path parameters

- `process_name` (string, required) — The process name. The process must declare `trigger.type: webhook`.

### Response

`201 application/json` — New instance object.

```json
{
  "id":      "01HXYZ_ACME_002",
  "process": "customer-onboarding",
  "state":   "running"
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 401 | `invalid_signature` | HMAC signature did not match. |
| 500 | `trigger_misconfigured` | Engine env var for HMAC secret is unset. |
| 404 | `not_found` | No process with that name. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/triggers/customer-onboarding \
  -X POST \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=<hmac>" \
  -d '{"data":{"company":"Acme Corp","country":"US"}}'
```

#### Node

```js
// Sign the body with your shared secret first
await fetch(
  "https://api.opensop.dev/sop/triggers/customer-onboarding",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Hub-Signature-256": sig
    },
    body: payload
  }
);
```

#### Python

```python
requests.post(
  "https://api.opensop.dev/sop/triggers/customer-onboarding",
  data=body,
  headers={
    "Content-Type": "application/json",
    "X-Hub-Signature-256": sig
  }
)
```

#### Ruby

```ruby
req = Net::HTTP::Post.new("/sop/triggers/customer-onboarding")
req["X-Hub-Signature-256"] = sig
req["Content-Type"]        = "application/json"
req.body = payload
Net::HTTP.start("api.opensop.dev", use_ssl: true) { |h| h.request(req) }
```


---

## Webhook callbacks


<a id="endpoint-deliver-callback"></a>

## `POST /sop/webhooks/:callback_id`

**Deliver a webhook callback** — POSTs back to a one-shot callback URL generated by the engine when a webhook step starts. The ULID in the path is the credential. Once consumed, the URL is invalidated and subsequent calls return 409.

> **Auth:** No bearer token required. The ULID in the path is the credential — it is single-use and time-limited.

### Path parameters

- `callback_id` (string ULID, required) — One-shot ULID generated by the engine when a webhook step starts. Obtain from the step's `callback_url` field.

### Response

`200 application/json` — Step advanced; instance continues execution.

```json
{
  "received": true,
  "step_id":   "verify-kyc"
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 404 | `not_found` | Callback ID does not exist or has expired. |
| 409 | `callback_already_resolved` | This URL was already consumed. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/webhooks/01HXYZ_CB_TOKEN \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"status":"success","document_id":"DOC-1234"}'
```

#### Node

```js
await fetch(
  `https://api.opensop.dev/sop/webhooks/${callbackId}`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ status: "success" })
  }
);
```

#### Python

```python
requests.post(
  f"https://api.opensop.dev/sop/webhooks/{callback_id}",
  json={"status": "success"}
)
```

#### Ruby

```ruby
req = Net::HTTP::Post.new("/sop/webhooks/#{callback_id}")
req["Content-Type"] = "application/json"
req.body = { status: "success" }.to_json
Net::HTTP.start("api.opensop.dev", use_ssl: true) { |h| h.request(req) }
```


---

## Metrics


<a id="endpoint-show-metrics"></a>

## `GET /sop/metrics`

**Get metrics** — Returns aggregate throughput and success metrics for all processes over the last 24 hours.

> **Auth:** Requires `X-SOP-Token` header. See [Authentication](/api-docs/guides/authentication.md).

### Response

`200 application/json` — Aggregate metrics object.

```json
{
  "window":          "24h",
  "started":          42,
  "completed":        38,
  "failed":            2,
  "completion_rate":  0.95,
  "avg_duration_ms":  84320,
  "p95_duration_ms":  192000
}
```

### Errors

| Status | Code | Meaning |
|--------|------|---------|
| 401 | `unauthorized` | Token missing or revoked. |

### Code examples

#### curl

```bash
curl https://api.opensop.dev/sop/metrics \
  -H "X-SOP-Token: $OPENSOP_TOKEN"
```

#### Node

```js
const metrics = await fetch(
  "https://api.opensop.dev/sop/metrics",
  { headers: { "X-SOP-Token": process.env.OPENSOP_TOKEN } }
).then(r => r.json());
```

#### Python

```python
resp = requests.get(
  "https://api.opensop.dev/sop/metrics",
  headers={"X-SOP-Token": os.environ["OPENSOP_TOKEN"]}
)
```

#### Ruby

```ruby
Net::HTTP.start("api.opensop.dev", use_ssl: true) do |h|
  req = Net::HTTP::Get.new("/sop/metrics")
  req["X-SOP-Token"] = ENV["OPENSOP_TOKEN"]
  puts h.request(req).body
end
```


---
