A standalone, language-neutral workflow orchestration server. Write durable workflows in any language. Built on the same engine as Durable Workflow.
# Clone the repository
git clone https://github.com/durable-workflow/server.git
cd server
# Copy environment config
cp .env.example .env
# Start the server with all dependencies
docker compose up -d
# Verify
curl http://localhost:8080/api/healthCompose runs a one-shot bootstrap service before the API and worker
containers start. That service calls the image's server-bootstrap command,
which runs migrations and seeds the default namespace.
The image build fetches the durable-workflow/workflow v2 package source by
default so docker compose up --build works from a clean checkout. Override
WORKFLOW_PACKAGE_SOURCE or WORKFLOW_PACKAGE_REF if you need a different
package remote or ref during image builds.
# Install the CLI
composer global require durable-workflow/cli
# Start a workflow
dw workflow start --type=my-workflow --input='{"name":"world"}'
# List workflows
dw workflow list
# Check server health
dw server healthThis walkthrough shows the full lifecycle using curl — start the server,
create a workflow, poll for tasks, and complete them. Any HTTP client in any
language follows the same steps.
Set role tokens for convenience (or set WORKFLOW_SERVER_AUTH_DRIVER=none in
.env to skip auth during development). If you only configure the legacy
WORKFLOW_SERVER_AUTH_TOKEN, use the same value for each variable below.
export ADMIN_TOKEN="your-admin-token"
export OPERATOR_TOKEN="your-operator-token"
export WORKER_TOKEN="your-worker-token"
export SERVER="http://localhost:8080"curl $SERVER/api/health{"status":"serving","timestamp":"2026-04-13T12:00:00Z"}The bootstrap seeds a default namespace. To create a dedicated one:
curl -X POST $SERVER/api/namespaces \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"my-app","description":"My application namespace","retention_days":30}'curl -X POST $SERVER/api/workflows \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Control-Plane-Version: 2" \
-d '{
"workflow_id": "order-42",
"workflow_type": "orders.process",
"task_queue": "order-workers",
"input": ["order-42", {"rush": true}],
"execution_timeout_seconds": 3600,
"run_timeout_seconds": 600
}'{
"workflow_id": "order-42",
"run_id": "abc123",
"workflow_type": "orders.process",
"status": "pending",
"outcome": "started_new"
}Before polling, register the worker with the server:
curl -X POST $SERVER/api/worker/register \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"worker_id": "worker-1",
"task_queue": "order-workers",
"runtime": "python"
}'The server holds the connection open (long-poll) until a task is ready or the timeout expires:
curl -X POST $SERVER/api/worker/workflow-tasks/poll \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"worker_id": "worker-1",
"task_queue": "order-workers"
}'The response includes the task, its history events, and lease metadata:
{
"protocol_version": "1.0",
"task": {
"task_id": "task-xyz",
"workflow_id": "order-42",
"run_id": "abc123",
"workflow_type": "orders.process",
"workflow_task_attempt": 1,
"lease_owner": "worker-1",
"task_queue": "order-workers",
"history_events": [
{"sequence": 1, "event_type": "StartAccepted", "...": "..."},
{"sequence": 2, "event_type": "WorkflowStarted", "...": "..."}
]
}
}Replay history, execute logic, and return commands. To schedule an activity:
curl -X POST $SERVER/api/worker/workflow-tasks/task-xyz/complete \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"lease_owner": "worker-1",
"workflow_task_attempt": 1,
"commands": [
{
"type": "schedule_activity",
"activity_type": "orders.send-confirmation",
"task_queue": "order-workers",
"input": ["order-42"]
}
]
}'To complete the workflow (terminal command):
curl -X POST $SERVER/api/worker/workflow-tasks/task-xyz/complete \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"lease_owner": "worker-1",
"workflow_task_attempt": 1,
"commands": [
{
"type": "complete_workflow",
"result": {"status": "shipped", "tracking": "TRK-123"}
}
]
}'If the workflow scheduled activities, poll for them on the same (or different) queue:
# Poll
curl -X POST $SERVER/api/worker/activity-tasks/poll \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{"worker_id": "worker-1", "task_queue": "order-workers"}'
# Complete (use task_id and activity_attempt_id from the poll response)
curl -X POST $SERVER/api/worker/activity-tasks/TASK_ID/complete \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Protocol-Version: 1.0" \
-d '{
"activity_attempt_id": "ATTEMPT_ID",
"lease_owner": "worker-1",
"result": "confirmation-sent"
}'curl $SERVER/api/workflows/order-42 \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Control-Plane-Version: 2"curl "$SERVER/api/workflows/order-42/runs/abc123/history" \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "X-Namespace: default" \
-H "X-Durable-Workflow-Control-Plane-Version: 2"| Command | Terminal | Description |
|---|---|---|
complete_workflow |
Yes | Complete workflow with a result |
fail_workflow |
Yes | Fail workflow with an error |
continue_as_new |
Yes | Continue as a new run |
schedule_activity |
No | Schedule an activity for execution |
start_timer |
No | Start a durable timer |
start_child_workflow |
No | Start a child workflow |
record_side_effect |
No | Record a non-deterministic value |
record_version_marker |
No | Record a version marker |
upsert_search_attributes |
No | Update search attributes |
GET /api/health— Health checkGET /api/cluster/info— Server capabilities and versionGET /api/system/repair— Task repair diagnosticsPOST /api/system/repair/pass— Run task repair sweepGET /api/system/activity-timeouts— Expired activity execution diagnosticsPOST /api/system/activity-timeouts/pass— Enforce activity timeouts
GET /api/namespaces— List namespacesPOST /api/namespaces— Create namespaceGET /api/namespaces/{name}— Get namespacePUT /api/namespaces/{name}— Update namespace
GET /api/workflows— List workflows (with filters)POST /api/workflows— Start a workflowGET /api/workflows/{id}— Describe a workflowGET /api/workflows/{id}/runs— List runs (continue-as-new chain)GET /api/workflows/{id}/runs/{runId}— Describe a specific runPOST /api/workflows/{id}/signal/{name}— Send a signalPOST /api/workflows/{id}/query/{name}— Execute a queryPOST /api/workflows/{id}/update/{name}— Execute an updatePOST /api/workflows/{id}/cancel— Request cancellationPOST /api/workflows/{id}/terminate— Terminate immediately
GET /api/workflows/{id}/runs/{runId}/history— Get event historyGET /api/workflows/{id}/runs/{runId}/history/export— Export replay bundle
Workflow and history control-plane requests must send
X-Durable-Workflow-Control-Plane-Version: 2. Requests without that header or
with legacy wait_policy fields are rejected. Workflow and history responses
always return the same header. The v2 canonical workflow command fields are
workflow_id, command_status, outcome, plus signal_name, query_name,
or update_name where applicable and, for updates, wait_for,
wait_timed_out, and wait_timeout_seconds.
Workflow control-plane responses also publish a nested, independently versioned
control_plane.contract boundary with:
schema: durable-workflow.v2.control-plane-response.contractversion: 1legacy_field_policy: reject_non_canonicallegacy_fields,required_fields, andsuccess_fields
Clients can validate that nested contract separately from the outer
control_plane envelope.
The server also publishes the current request contract in
GET /api/cluster/info under control_plane.request_contract with:
schema: durable-workflow.v2.control-plane-request.contractversion: 1operations
Treat that versioned manifest as the source of truth for canonical request
values, rejected aliases, and removed fields such as start
duplicate_policy and update wait_for. Clients should reject missing or
unknown request-contract schema or version instead of silently guessing.
POST /api/worker/register— Register a workerPOST /api/worker/heartbeat— Worker heartbeatPOST /api/worker/workflow-tasks/poll— Long-poll for workflow tasksPOST /api/worker/workflow-tasks/{id}/heartbeat— Workflow task heartbeatPOST /api/worker/workflow-tasks/{id}/complete— Complete workflow taskPOST /api/worker/workflow-tasks/{id}/fail— Fail workflow taskPOST /api/worker/activity-tasks/poll— Long-poll for activity tasksPOST /api/worker/activity-tasks/{id}/complete— Complete activity taskPOST /api/worker/activity-tasks/{id}/fail— Fail activity taskPOST /api/worker/activity-tasks/{id}/heartbeat— Activity heartbeat
Worker-plane requests must send X-Durable-Workflow-Protocol-Version: 1.0, and
worker-plane responses always echo the same header plus protocol_version: "1".
Worker registration, poll, heartbeat, complete, and fail responses all include
server_capabilities.supported_workflow_task_commands so SDK workers can
negotiate whether the server only supports terminal workflow-task commands or
the expanded non-terminal command set.
Long-poll wake-ups use short-lived cache-backed signal keys plus periodic reprobes. Multi-node deployments therefore need a shared cache backend for prompt wake behavior; without one, correctness still comes from the periodic database recheck, but wake latency will regress toward the forced recheck interval.
Within worker protocol version 1, worker_protocol.version,
server_capabilities.long_poll_timeout, and
server_capabilities.supported_workflow_task_commands are stable contract
fields. Adding new workflow-task commands is additive; removing or renaming a
command requires a protocol version bump.
Workflow task polling returns a leased task plus workflow_task_attempt. Clients
must echo both workflow_task_attempt and lease_owner on workflow-task
heartbeat, complete, and fail calls. Workflow-task completion supports
non-terminal commands such as schedule_activity, start_timer, and
start_child_workflow, plus terminal complete_workflow, fail_workflow,
and continue_as_new commands.
Activity task polling returns a leased attempt identity. Clients must echo both
activity_attempt_id and lease_owner on activity complete, fail, and
heartbeat calls. When the activity execution has timeout deadlines configured,
the poll response includes a deadlines object with ISO-8601 timestamps for
schedule_to_start, start_to_close, schedule_to_close, and/or heartbeat.
Workers should use these deadlines to self-cancel before the server enforces the
timeout. The server runs activity:timeout-enforce periodically to expire
activities that exceed their deadlines. Heartbeats accept message, current,
total, unit, and details fields; the server normalizes them to the package
heartbeat-progress contract before recording the heartbeat.
GET /api/schedules— List schedulesPOST /api/schedules— Create scheduleGET /api/schedules/{id}— Describe schedulePUT /api/schedules/{id}— Update scheduleDELETE /api/schedules/{id}— Delete schedulePOST /api/schedules/{id}/pause— Pause schedulePOST /api/schedules/{id}/resume— Resume schedulePOST /api/schedules/{id}/trigger— Trigger immediatelyPOST /api/schedules/{id}/backfill— Backfill missed runs
GET /api/task-queues— List task queuesGET /api/task-queues/{name}— Task queue details and pollers
GET /api/search-attributes— List search attributesPOST /api/search-attributes— Register custom attributeDELETE /api/search-attributes/{name}— Remove custom attribute
Set the X-Namespace header to target a specific namespace (defaults to default).
For production, prefer role-scoped tokens:
WORKFLOW_SERVER_AUTH_DRIVER=token
WORKFLOW_SERVER_WORKER_TOKEN=worker-secret
WORKFLOW_SERVER_OPERATOR_TOKEN=operator-secret
WORKFLOW_SERVER_ADMIN_TOKEN=admin-secretworker tokens can call /api/worker/* and /api/cluster/info. operator
tokens can call workflow, history, schedule, search-attribute, task-queue,
worker-read, and namespace-read endpoints. admin tokens can call admin
operations such as /api/system/*, namespace creation/update, and worker
deletion, and can also use operator endpoints.
curl -H "Authorization: Bearer operator-secret" http://localhost:8080/api/workflowsExisting deployments can keep WORKFLOW_SERVER_AUTH_TOKEN. When no role tokens
are configured, that legacy token keeps full API access. Once any role token is
configured, the legacy token is treated as an admin token and no longer grants
worker-plane access. Set WORKFLOW_SERVER_AUTH_BACKWARD_COMPATIBLE=false to
require role-scoped credentials only.
Signature auth supports the same role split with role-scoped HMAC keys:
WORKFLOW_SERVER_AUTH_DRIVER=signature
WORKFLOW_SERVER_WORKER_SIGNATURE_KEY=worker-hmac-key
WORKFLOW_SERVER_OPERATOR_SIGNATURE_KEY=operator-hmac-key
WORKFLOW_SERVER_ADMIN_SIGNATURE_KEY=admin-hmac-key# HMAC-SHA256 of the request body
curl -H "X-Signature: COMPUTED_SIGNATURE" http://localhost:8080/api/workflowsThe legacy WORKFLOW_SERVER_SIGNATURE_KEY follows the same compatibility rule
as the legacy bearer token.
Set WORKFLOW_SERVER_AUTH_DRIVER=none to disable authentication (development only).
docker build -t durable-workflow-server .
# Bootstrap schema + default namespace once
docker run --rm --env-file .env durable-workflow-server server-bootstrap
# Start the API server
docker run --rm -p 8080:8080 --env-file .env durable-workflow-serverThe Dockerfile clones the durable-workflow/workflow v2 branch into the
build and satisfies the app's Composer path repository from that source. Use
--build-arg WORKFLOW_PACKAGE_SOURCE=... and
--build-arg WORKFLOW_PACKAGE_REF=... to point the image build at another
remote or ref when needed.
Across Compose, plain Docker, and Kubernetes, the supported bootstrap contract
is the same: run the image's server-bootstrap command once before starting the
server and worker processes.
# Create namespace and secrets
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/secret.yaml # Edit secrets first!
# Run bootstrap job
kubectl apply -f k8s/migration-job.yaml
# Deploy server and workers
kubectl apply -f k8s/server-deployment.yaml
kubectl apply -f k8s/worker-deployment.yamlAll configuration is via environment variables. See .env.example for the full list.
Workers in any language connect to the server via HTTP. The protocol is simple:
- Register the worker with supported types
- Poll for tasks (long-poll, server holds connection)
- Execute the task locally
- Complete or fail the task back to the server
- Heartbeat for long-running activities
use DurableWorkflow\Client;
use DurableWorkflow\Worker;
$client = new Client('http://localhost:8080', token: 'WORKER_TOKEN');
$worker = new Worker($client, taskQueue: 'default');
$worker->registerWorkflow(MyWorkflow::class);
$worker->registerActivity(MyActivity::class);
$worker->run();from durable_workflow import Client, Worker, workflow, activity
client = Client("http://localhost:8080", token="WORKER_TOKEN", namespace="default")
worker = Worker(
client,
task_queue="default",
workflows=[MyWorkflow],
activities=[my_activity],
)
await worker.run()MIT