Skip to main content
Version: 0.2.0

REST API Guide

This is the narrative companion to api/openapi.yaml. It describes how to drive the go-saga-orchestration engine over HTTP: the base URL and ports, the saga lifecycle, the live stream format, the error conventions, and a runnable curl example per endpoint group.

All schemas in this guide are derived directly from the Go source (internal/api/handler_*.go, internal/api/response.go, internal/domain/*.go, internal/store/store.go, internal/rules/rules.go) and the handler tests.

Base URL and portsโ€‹

The REST API is served by the api binary (cmd/api). It listens on :8080 by default. The port is configurable via the WORKFLOW_API_PORT environment variable.

http://localhost:8080

(The engine binary, cmd/engine, exposes a separate gRPC port โ€” default 9090, env WORKFLOW_ENGINE_GRPC_PORT โ€” and is not part of this REST surface.)

There is no authentication wired today. The stream endpoint explicitly notes that auth middleware will land in a later batch.

Endpoint overviewโ€‹

MethodPathPurpose
GET/health/liveLiveness probe
GET/health/readyReadiness probe
GET/api/v1/sagasList/filter saga runs (paginated)
POST/api/v1/sagas/startStart a saga run
GET/api/v1/sagas/{id}Get one saga run
POST/api/v1/sagas/{run_id}/signal/{name}Deliver an external signal
POST/api/v1/sagas/{run_id}/user_task/{task_id}/submitSubmit a user task result
GET/api/v1/sagas/{run_id}/streamLive run inspector (WebSocket)
POST/api/v1/registry/registerRegister a service's actions
GET/api/v1/registry/actionsList registered actions
POST/api/v1/rules/{rule_id}/evaluateEvaluate a rule
POST/api/v1/triggersCreate a trigger
GET/api/v1/triggersList triggers
GET/api/v1/triggers/{id}Get one trigger
DELETE/api/v1/triggers/{id}Delete a trigger
GET/api/v1/workflows/{wf_id}/statsAggregate workflow stats

The saga lifecycleโ€‹

A saga is one running instance of a workflow definition. The typical lifecycle:

  1. Start โ€” POST /api/v1/sagas/start with a workflow_id and inputs. The engine resolves the published definition, creates a run in pending state, publishes a saga.advance message, and returns 202 with { "saga_run_id": "<uuid>" }. The run then progresses through states: pending โ†’ running โ†’ (paused) โ†’ succeeded | failed | cancelled (with compensating during rollback). See the RunState enum.

  2. Wait points โ€” when a workflow reaches a wait_for_signal, manual_approval, or collect_input step, the run moves to paused and records what it is awaiting (awaited_signal, etc.).

  3. Resume โ€” there are two ways to wake a paused run:

    • Signal: POST /api/v1/sagas/{run_id}/signal/{name}. The signal is always recorded. If the run was paused awaiting exactly this signal name, the server consumes it and publishes saga.advance, returning 202. If the run was not paused-and-awaiting this name, it returns 409 (the signal is still recorded, but nothing advances).
    • User task submit: POST /api/v1/sagas/{run_id}/user_task/{task_id}/submit. This persists the task result, then internally appends a signal named user_task.{task_id}.submitted carrying the result as its payload, and advances the saga the same way a signal would. Always returns 202 on success.
  4. Observe โ€” GET /api/v1/sagas/{run_id}/stream (WebSocket) tails the run live; GET /api/v1/sagas/{id} fetches the current snapshot; and GET /api/v1/sagas lists/filters runs.

The saga run objectโ€‹

GET /api/v1/sagas/{id} returns a SagaRun (the full Go struct, JSON-tagged):

{
"id": "f1e2d3c4-0000-0000-0000-000000000000",
"workflow_id": "example_workflow_v1",
"definition_id": "a1b2c3d4-0000-0000-0000-000000000000",
"tenant_id": null,
"state": "paused",
"current_step": "await_approval",
"inputs": { "order_id": "ORD-123" },
"variables": {},
"started_at": "2026-05-29T12:00:00Z",
"last_event_at": "2026-05-29T12:00:05Z",
"requires_manual_review": false,
"awaited_signal": "approval.decided",
"current_attempt": 0
}

Optional fields (terminal_at, trigger_id, parent_run_id, wakeup_at, feature_overrides, dry_run, etc.) are omitted when empty.

Listing runsโ€‹

GET /api/v1/sagas is paginated (limit 1โ€“500, default 50; offset >= 0, default 0) and supports filters: workflow_id, state, trigger_type, since (RFC3339), has_error (bool), requires_review (bool). The response always includes a non-null sagas array plus total, limit, offset.

X-Feature-Override header (start only)โ€‹

POST /api/v1/sagas/start accepts an optional X-Feature-Override header to override license feature flags on a per-request basis. This is valid in any environment โ€” standalone, on-prem, dev, or QA โ€” not a QA-only facility. Format: comma-separated feature=value pairs, e.g. wf.parallel=on,wf.timers=off. Values on/true/1 => true; off/false/0 => false; anything else is silently ignored.

The stream event formatโ€‹

Note: Despite being listed as an HTTP endpoint, stream upgrades to a WebSocket using gorilla/websocket. The documentation below reflects the actual handler implementation.

GET /api/v1/sagas/{run_id}/stream validates the run exists (returning a JSON 400 for a bad UUID or 404 for an unknown run before upgrading, using the standard {"error": code, "message": msg} envelope), then upgrades the HTTP connection to a WebSocket. Messages are JSON text frames of the shape:

{ "type": "run", "data": { /* SagaRun snapshot */ } }
{ "type": "event", "data": { /* SagaRunEvent */ } }

On connect the server sends, in order:

  1. one run frame โ€” the current SagaRun snapshot,
  2. one event frame per existing audit event for the run,
  3. then a live event frame for each new event as it is recorded (tailed via Postgres LISTEN/NOTIFY on a per-run channel).

A SagaRunEvent looks like:

{
"id": "...",
"run_id": "...",
"step_id": "await_approval",
"attempt": 0,
"event_type": "step.paused",
"actor": "engine",
"recorded_at": "2026-05-29T12:00:05Z"
}

event_type is one of: saga.started, step.dispatched, step.started, step.succeeded, step.failed, step.skipped, step.paused, run.succeeded, run.failed, run.cancelled, compensation.started, log, metric, rule.evaluated, license.gate.rejected.

Error conventionsโ€‹

All API errors use a single structured JSON envelope:

{ "error": "saga_not_found", "message": "f1e2d3c4-..." }

error is a stable, machine-readable code; message is human-readable detail. On 5xx responses the real error is logged server-side and only a generic "internal error" message is returned to the client โ€” never raw internal detail. On 4xx the message may include safe client-input context (e.g. the offending field name).

Machine-readable codes include: bad_request, not_found, internal, invalid_config, publish_failed, unprocessable, conflict, workflow_not_found, saga_not_found, trigger_not_found.

curl examples by groupโ€‹

Healthโ€‹

curl -s http://localhost:8080/health/live
# {"status":"live"}

curl -s http://localhost:8080/health/ready
# {"status":"ready"}

Sagasโ€‹

# Start a saga
curl -s -X POST http://localhost:8080/api/v1/sagas/start \
-H 'Content-Type: application/json' \
-H 'X-Feature-Override: wf.parallel=on' \
-d '{"workflow_id":"example_workflow_v1","version":"latest","inputs":{"order_id":"ORD-123"}}'
# 202 {"saga_run_id":"<uuid>"}

# Get one run
curl -s http://localhost:8080/api/v1/sagas/<run_id>

# List/filter runs
curl -s 'http://localhost:8080/api/v1/sagas?workflow_id=example_workflow_v1&state=paused&limit=20'

# Deliver a signal (202 if it advanced a paused run, 409 otherwise)
curl -s -X POST http://localhost:8080/api/v1/sagas/<run_id>/signal/approval.decided \
-H 'Content-Type: application/json' \
-d '{"payload":{"approved":true}}'

# Submit a user task
curl -s -X POST http://localhost:8080/api/v1/sagas/<run_id>/user_task/<task_id>/submit \
-H 'Content-Type: application/json' \
-d '{"submitted_by":"alice@example.com","result":{"decision":"approve"}}'

# Stream (WebSocket) โ€” use a WS client, e.g. websocat
websocat ws://localhost:8080/api/v1/sagas/<run_id>/stream

Registryโ€‹

# Register actions (idempotent; call on service startup)
curl -s -X POST http://localhost:8080/api/v1/registry/register \
-H 'Content-Type: application/json' \
-d '{
"service":"example",
"service_version":"0.18.2",
"actions":[
{"action_name":"set_state","version":1,"category":"record_lifecycle","compensable":true,"input_schema":{},"output_schema":{}}
]
}'
# 200 {"service":"example","service_version":"0.18.2","registered":1}

# List actions
curl -s 'http://localhost:8080/api/v1/registry/actions?service=example&category=record_lifecycle'
# {"actions":[ ... ]}

Rulesโ€‹

curl -s -X POST http://localhost:8080/api/v1/rules/triage/evaluate \
-H 'Content-Type: application/json' \
-d '{"inputs":{"priority":"p1"}}'
# 200 {"output":{"branch":"high"},"audit":[{"index":0,"when":"priority == 'p1'","matched":true}]}

Triggersโ€‹

# Create
curl -s -X POST http://localhost:8080/api/v1/triggers \
-H 'Content-Type: application/json' \
-d '{
"trigger_type":"record_transition",
"workflow_id":"example_workflow_v1",
"version":1,
"config":{"record_type":"order","from_state":"created","to_state":"pending_review"},
"enabled":true,
"created_by":"admin"
}'
# 201 โ€” body uses PascalCase field names (see note below)

# List (optional ?type= and ?enabled=true|false|1|0)
curl -s 'http://localhost:8080/api/v1/triggers?type=record_transition&enabled=true'
# {"triggers":[ ... ]}

# Get one
curl -s http://localhost:8080/api/v1/triggers/<id>

# Delete (204 on success, 404 if missing)
curl -s -X DELETE http://localhost:8080/api/v1/triggers/<id>

Workflowsโ€‹

curl -s http://localhost:8080/api/v1/workflows/example_workflow_v1/stats
# {"workflow_id":"example_workflow_v1","success_rate_24h":0.83,"last_run_at":"2026-05-29T12:00:00Z","in_flight":2}

Assumptions and ambiguities resolvedโ€‹

  • Stream is WebSocket, not SSE. The brief said SSE; the code (handler_stream.go) uses gorilla/websocket and emits {type, data} JSON frames over a WebSocket. Documented as WebSocket. The OpenAPI spec models the endpoint with a 101 Switching Protocols response and documents the StreamFrame schema, since OpenAPI 3.0/3.1 cannot natively describe WebSocket message streams.

  • SagaTrigger is serialized with PascalCase keys. The domain.SagaTrigger struct has no JSON tags, so Go's encoding/json emits the exact Go field names: ID, TriggerType, WorkflowID, Version, Config, Enabled, TenantID, CreatedAt, CreatedBy. The handler tests confirm this by round-tripping responses into domain.SagaTrigger. The request body (TriggerCreateRequest), by contrast, is a separate struct with snake_case JSON tags. Both shapes are documented faithfully and differ on purpose.

  • Single JSON error contract. All handlers use WriteError producing {"error": code, "message": msg}. The PlainTextError OpenAPI component and the legacy http.Error call sites have been removed.

  • config map values for triggers and rule inputs/output / action input_schema/output_schema are free-form JSON objects (Go map[string]any), so they are modeled as objects with additionalProperties: true. For trigger_type: record_transition the server additionally requires config.record_type, config.from_state, and config.to_state to be non-empty strings (validated server-side; returns 422 invalid_config on failure).

  • tenant_id typing differs by endpoint. On POST /sagas/start it is a UUID (*uuid.UUID). On POST /triggers and POST /rules/.../evaluate it is a string in the request body that the server attempts to parse as a UUID (invalid values are silently dropped rather than rejected). Modeled per the actual struct types.

  • success_rate_24h and last_run_at are nullable in WorkflowStats (pointers in Go): success_rate_24h is null when there were no runs in the last 24h; last_run_at is null when there have been no runs at all.

  • Signal/user-task success responses have empty bodies (the handlers only call w.WriteHeader), so no response schema is defined for their 2xx/4xx status codes beyond the status itself.