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โ
| Method | Path | Purpose |
|---|---|---|
| GET | /health/live | Liveness probe |
| GET | /health/ready | Readiness probe |
| GET | /api/v1/sagas | List/filter saga runs (paginated) |
| POST | /api/v1/sagas/start | Start 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}/submit | Submit a user task result |
| GET | /api/v1/sagas/{run_id}/stream | Live run inspector (WebSocket) |
| POST | /api/v1/registry/register | Register a service's actions |
| GET | /api/v1/registry/actions | List registered actions |
| POST | /api/v1/rules/{rule_id}/evaluate | Evaluate a rule |
| POST | /api/v1/triggers | Create a trigger |
| GET | /api/v1/triggers | List triggers |
| GET | /api/v1/triggers/{id} | Get one trigger |
| DELETE | /api/v1/triggers/{id} | Delete a trigger |
| GET | /api/v1/workflows/{wf_id}/stats | Aggregate workflow stats |
The saga lifecycleโ
A saga is one running instance of a workflow definition. The typical lifecycle:
-
Start โ
POST /api/v1/sagas/startwith aworkflow_idandinputs. The engine resolves the published definition, creates a run inpendingstate, publishes asaga.advancemessage, and returns202with{ "saga_run_id": "<uuid>" }. The run then progresses through states:pending โ running โ (paused) โ succeeded | failed | cancelled(withcompensatingduring rollback). See theRunStateenum. -
Wait points โ when a workflow reaches a
wait_for_signal,manual_approval, orcollect_inputstep, the run moves topausedand records what it is awaiting (awaited_signal, etc.). -
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 publishessaga.advance, returning202. If the run was not paused-and-awaiting this name, it returns409(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 nameduser_task.{task_id}.submittedcarrying the result as its payload, and advances the saga the same way a signal would. Always returns202on success.
- Signal:
-
Observe โ
GET /api/v1/sagas/{run_id}/stream(WebSocket) tails the run live;GET /api/v1/sagas/{id}fetches the current snapshot; andGET /api/v1/sagaslists/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,
streamupgrades to a WebSocket usinggorilla/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:
- one
runframe โ the currentSagaRunsnapshot, - one
eventframe per existing audit event for the run, - then a live
eventframe 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) usesgorilla/websocketand emits{type, data}JSON frames over a WebSocket. Documented as WebSocket. The OpenAPI spec models the endpoint with a101 Switching Protocolsresponse and documents theStreamFrameschema, since OpenAPI 3.0/3.1 cannot natively describe WebSocket message streams. -
SagaTriggeris serialized with PascalCase keys. Thedomain.SagaTriggerstruct has no JSON tags, so Go'sencoding/jsonemits the exact Go field names:ID,TriggerType,WorkflowID,Version,Config,Enabled,TenantID,CreatedAt,CreatedBy. The handler tests confirm this by round-tripping responses intodomain.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
WriteErrorproducing{"error": code, "message": msg}. ThePlainTextErrorOpenAPI component and the legacyhttp.Errorcall sites have been removed. -
configmap values for triggers and ruleinputs/output/ actioninput_schema/output_schemaare free-form JSON objects (Gomap[string]any), so they are modeled as objects withadditionalProperties: true. Fortrigger_type: record_transitionthe server additionally requiresconfig.record_type,config.from_state, andconfig.to_stateto be non-empty strings (validated server-side; returns422invalid_configon failure). -
tenant_idtyping differs by endpoint. OnPOST /sagas/startit is a UUID (*uuid.UUID). OnPOST /triggersandPOST /rules/.../evaluateit 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_24handlast_run_atare nullable inWorkflowStats(pointers in Go):success_rate_24his null when there were no runs in the last 24h;last_run_atis 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.