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 |
| POST | /api/v1/sagas/{run_id}/actions/{step_id}/result | Report an action result (http/rmq workers) |
| 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)
# An action may carry an optional dispatch descriptor โ "transport"
# (grpc | http | rmq) and "address" (callback URL for http, queue name for
# rmq; required only for http/rmq). Omit it for the gRPC default.
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":{}},
{"action_name":"send_email","version":1,"input_schema":{},"output_schema":{},"transport":"http","address":"https://worker.example.com/actions/send_email"}
]
}'
# 200 {"service":"example","service_version":"0.18.2","registered":2}
# List actions (descriptor is echoed back)
curl -s 'http://localhost:8080/api/v1/registry/actions?service=example&category=record_lifecycle'
# {"actions":[ ... ]}
Action result callback (http / rmq workers)โ
POST /api/v1/sagas/{run_id}/actions/{step_id}/result
gRPC workers reply over the ExecuteStep stream. Workers reached over the
http or rmq transport have no return stream, so they report their result
asynchronously here. The endpoint applies the same CompleteAction /
FailAction semantics as the gRPC path: success merges the result into the
run's variables and resumes the saga; failure transitions the run to failed.
Attempt handling and idempotency are preserved โ a stale attempt is a no-op.
Send exactly one of result or error. attempt is optional; omitted,
it defaults to the run's current attempt (the only in-flight dispatch).
# Success โ completes the action and advances the saga.
curl -s -X POST http://localhost:8080/api/v1/sagas/<run_id>/actions/<step_id>/result \
-H 'Content-Type: application/json' \
-d '{"result":{"ticket_number":"INC-999"}}'
# 202
# Failure โ transitions the run to failed.
curl -s -X POST http://localhost:8080/api/v1/sagas/<run_id>/actions/<step_id>/result \
-H 'Content-Type: application/json' \
-d '{"error":{"code":"ERR_WORKER_CRASH","message":"worker panicked","retryable":false}}'
# 202
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.