This document describes the REST API for managing workflows, processes, and background jobs in Sandbar.
Sandbar provides a workflow engine for managing state machines and a job system for background task execution.
All endpoints require authentication. Include your session cookie or authentication header with each request.
Workflow definitions describe state machines that can be instantiated as processes.
GET /api/workflows
Response:
{
"count": 2,
"workflows": [
{
"id": 17592186045421,
"name": ":workflow/order-fulfillment",
"version": 1,
"states": [
{"id": 17592186045422, "name": ":order/pending", "label": "Pending", "initial?": true, "terminal?": false},
{"id": 17592186045423, "name": ":order/confirmed", "label": "Confirmed", "initial?": false, "terminal?": false},
{"id": 17592186045424, "name": ":order/shipped", "label": "Shipped", "initial?": false, "terminal?": false},
{"id": 17592186045425, "name": ":order/delivered", "label": "Delivered", "initial?": false, "terminal?": true},
{"id": 17592186045426, "name": ":order/cancelled", "label": "Cancelled", "initial?": false, "terminal?": true}
],
"transitions": [
{"id": 17592186045427, "name": ":confirm", "from-state": 17592186045422, "to-state": 17592186045423, "requires-reason?": false},
{"id": 17592186045428, "name": ":ship", "from-state": 17592186045423, "to-state": 17592186045424, "requires-reason?": false},
{"id": 17592186045429, "name": ":deliver", "from-state": 17592186045424, "to-state": 17592186045425, "requires-reason?": false},
{"id": 17592186045430, "name": ":cancel", "from-state": 17592186045422, "to-state": 17592186045426, "requires-reason?": true}
],
"initial-state": {"id": 17592186045422, "name": ":order/pending", "label": "Pending", "initial?": true, "terminal?": false},
"terminal-states": [
{"id": 17592186045425, "name": ":order/delivered", "label": "Delivered", "initial?": false, "terminal?": true},
{"id": 17592186045426, "name": ":order/cancelled", "label": "Cancelled", "initial?": false, "terminal?": true}
]
}
]
}
GET /api/workflows/:ns/:name
Example:
GET /api/workflows/workflow/order-fulfillment
Response:
{
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment",
"version": 1,
"states": [...],
"transitions": [...],
"initial-state": {...},
"terminal-states": [...]
}
}
POST /api/workflows
Content-Type: application/edn
Request Body:
{:name :workflow/order-fulfillment
:version 1
:states [{:name :order/pending :label "Pending" :initial? true}
{:name :order/confirmed :label "Confirmed"}
{:name :order/shipped :label "Shipped"}
{:name :order/delivered :label "Delivered" :terminal? true}
{:name :order/cancelled :label "Cancelled" :terminal? true}]
:transitions [{:name :confirm :from :order/pending :to :order/confirmed}
{:name :ship :from :order/confirmed :to :order/shipped}
{:name :deliver :from :order/shipped :to :order/delivered}
{:name :cancel :from :order/pending :to :order/cancelled :requires-reason? true}
{:name :cancel :from :order/confirmed :to :order/cancelled :requires-reason? true}]}
Response (201 Created):
{
"created": true,
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment",
"version": 1,
...
}
}
GET /api/workflows/:ns/:name/stats
Example:
GET /api/workflows/workflow/order-fulfillment/stats
Response:
{
"workflow": ":workflow/order-fulfillment",
"stats": {
"by-state": {
":order/pending": 5,
":order/confirmed": 3,
":order/shipped": 2,
":order/delivered": 15,
":order/cancelled": 1
},
"completed": 16,
"active": 10,
"total": 26
}
}
Processes are running instances of workflows attached to subject entities.
GET /api/processes
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
workflow | keyword | Filter by workflow name (e.g., workflow/order-fulfillment) |
state | keyword | Filter by current state (e.g., order/pending) |
active | boolean | Only active (non-completed) processes |
completed | boolean | Only completed processes |
subject | long | Filter by subject entity ID |
limit | long | Maximum results (default: 100) |
Example:
GET /api/processes?workflow=workflow/order-fulfillment&active=true&limit=50
Response:
{
"count": 10,
"limit": 50,
"filters": {
"workflow": ":workflow/order-fulfillment",
"active": true
},
"processes": [
{
"id": 17592186045500,
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment"
},
"subject-id": 17592186045450,
"current-state": {
"id": 17592186045423,
"name": ":order/confirmed",
"label": "Confirmed",
"initial?": false,
"terminal?": false
},
"started-at": "2024-01-15T10:30:00.000Z",
"completed-at": null,
"completed?": false,
"in-terminal-state?": false,
"data": {"order-number": "ORD-001"}
}
]
}
GET /api/processes/:id
Example:
GET /api/processes/17592186045500
Response:
{
"process": {
"id": 17592186045500,
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment"
},
"subject-id": 17592186045450,
"current-state": {
"id": 17592186045423,
"name": ":order/confirmed",
"label": "Confirmed",
"initial?": false,
"terminal?": false
},
"started-at": "2024-01-15T10:30:00.000Z",
"completed-at": null,
"completed?": false,
"in-terminal-state?": false,
"data": {"order-number": "ORD-001"}
},
"available-transitions": [
{"name": ":ship", "requires-reason?": false},
{"name": ":cancel", "requires-reason?": true}
]
}
POST /api/processes
Content-Type: application/edn
Request Body:
{:workflow :workflow/order-fulfillment
:subject 17592186045450
:data {:order-number "ORD-001"
:customer-name "Zorp"}}
Response (201 Created):
{
"created": true,
"process": {
"id": 17592186045500,
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment"
},
"subject-id": 17592186045450,
"current-state": {
"id": 17592186045422,
"name": ":order/pending",
"label": "Pending",
"initial?": true,
"terminal?": false
},
"started-at": "2024-01-15T10:30:00.000Z",
"completed-at": null,
"completed?": false,
"in-terminal-state?": false,
"data": {"order-number": "ORD-001", "customer-name": "Zorp"}
}
}
GET /api/processes/:id/transitions
Example:
GET /api/processes/17592186045500/transitions
Response:
{
"process-id": 17592186045500,
"current-state": {
"id": 17592186045423,
"name": ":order/confirmed",
"label": "Confirmed",
"initial?": false,
"terminal?": false
},
"transitions": [
{
"name": ":ship",
"to-state": {
"id": 17592186045424,
"name": ":order/shipped",
"label": "Shipped",
"initial?": false,
"terminal?": false
},
"requires-reason?": false,
"has-guard?": false
},
{
"name": ":cancel",
"to-state": {
"id": 17592186045426,
"name": ":order/cancelled",
"label": "Cancelled",
"initial?": false,
"terminal?": true
},
"requires-reason?": true,
"has-guard?": false
}
]
}
POST /api/processes/:id/transition
Content-Type: application/edn
Request Body:
{:transition :ship
:reason "Package ready for carrier pickup"
:context {:tracking-number "1Z999AA10123456784"}}
Response:
{
"success": true,
"process": {
"id": 17592186045500,
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment"
},
"subject-id": 17592186045450,
"current-state": {
"id": 17592186045424,
"name": ":order/shipped",
"label": "Shipped",
"initial?": false,
"terminal?": false
},
"started-at": "2024-01-15T10:30:00.000Z",
"completed-at": null,
"completed?": false,
"in-terminal-state?": false,
"data": {"order-number": "ORD-001"}
},
"new-state": {
"id": 17592186045424,
"name": ":order/shipped",
"label": "Shipped",
"initial?": false,
"terminal?": false
},
"completed?": false
}
Error Response (transition not available):
{
"error": "Transition not available",
"transition": ":deliver",
"current-state": ":order/confirmed",
"available": [":ship", ":cancel"]
}
GET /api/processes/:id/history
Example:
GET /api/processes/17592186045500/history
Response:
{
"process-id": 17592186045500,
"count": 3,
"history": [
{
"action": ":confirm",
"from": ":order/pending",
"to": ":order/confirmed",
"timestamp": "2024-01-15T10:35:00.000Z",
"actor": 17592186045100,
"reason": null
},
{
"action": ":ship",
"from": ":order/confirmed",
"to": ":order/shipped",
"timestamp": "2024-01-16T14:20:00.000Z",
"actor": 17592186045100,
"reason": "Package ready for carrier pickup"
},
{
"action": ":deliver",
"from": ":order/shipped",
"to": ":order/delivered",
"timestamp": "2024-01-18T09:15:00.000Z",
"actor": 17592186045100,
"reason": "Delivered to customer"
}
]
}
POST /api/processes/:id/cancel
Content-Type: application/edn
Cancel a running workflow process. Calls workflow/cancel-process! after workflow/can-cancel? clears.
Request Body (optional):
{:reason "User requested cancellation"}
Success Response:
{
"success": true,
"process": {
"id": 17592186045500,
"workflow": {
"id": 17592186045421,
"name": ":workflow/order-fulfillment"
},
"subject-id": 17592186045450,
"current-state": {
"id": 17592186045499,
"name": ":order/cancelled",
"label": "Cancelled",
"initial?": false,
"terminal?": true
},
"started-at": "2024-01-15T10:30:00.000Z",
"completed-at": "2024-01-15T10:45:00.000Z",
"completed?": true,
"in-terminal-state?": true,
"data": {"order-number": "ORD-001", "cancel-reason": "User requested cancellation"}
}
}
Error Response (process cannot be cancelled):
{
"error": "Cannot cancel process",
"reason": ":already-completed",
"current-state": ":order/delivered"
}
Cancellation guard — workflow/can-cancel?:
can-cancel? returns false (and cancel-process! raises ExceptionInfo with :reason ex-data) when:
:reason | Meaning |
|---|---|
:already-completed | Process is already in a terminal state |
:not-cancellable | The workflow definition disallows cancellation from the current state |
:no-cancel-transition | No transition exists to a :cancelled-type terminal state |
The underlying functions are in sandbar.util.workflow:
(require '[sandbar.util.workflow :as workflow])
;; Check whether a process can be cancelled from its current state
(workflow/can-cancel? process)
;; => true / false
;; Cancel — raises ExceptionInfo with :reason on guard failure
(workflow/cancel-process! process)
;; or with a reason for the audit history:
(workflow/cancel-process! process :reason "User abandoned checkout")
;; Returns the updated process entity (in its terminal :cancelled state)
Stage C.7.4 of the Sandbar-as-MCP-Server arc landed these primitives upstream so that sandbar.mcp.tasks/handle-cancel could delegate rather than reach for raw datomic.api — preserving the layer-targeting discipline (see doc/mcp-server.md).
Long-running workflow processes are also exposed via the MCP Tasks primitive — see doc/tasks-api.md. The task-id IS the workflow process's :db/id (stringified); tasks/get projects current state; tasks/cancel calls workflow/cancel-process!. There is no parallel registry — the workflow process is the durable source of truth for both REST and MCP consumers.
Background jobs for scheduled, triggered, and recurring task execution.
:pending -> :running -> :completed
|
+-----> :failed
|
+-----> :cancelled
:paused (can be resumed to :pending)
GET /api/jobs
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
status | keyword | Filter by status (pending, running, completed, failed, cancelled, paused) |
queue | keyword | Filter by queue name |
tags | string | Filter by tags (comma-separated) |
limit | long | Maximum results (default: 100) |
Example:
GET /api/jobs?status=pending&queue=email&limit=50
Response:
{
"count": 5,
"limit": 50,
"filters": {
"status": ":pending",
"queue": ":email"
},
"jobs": [
{
"id": 17592186045600,
"name": "Send welcome email",
"handler": "myapp.jobs/send-email",
"status": ":pending",
"priority": 5,
"queue": ":email",
"attempt-count": 0,
"max-attempts": 3,
"created-at": "2024-01-15T10:00:00.000Z",
"run-at": "2024-01-15T10:05:00.000Z",
"next-run": null,
"last-run": null,
"run-count": null,
"tags": [":email", ":welcome"],
"error": null
}
]
}
GET /api/jobs/:id
Example:
GET /api/jobs/17592186045600
Response:
{
"job": {
"id": 17592186045600,
"name": "Send welcome email",
"handler": "myapp.jobs/send-email",
"status": ":pending",
"priority": 5,
"queue": ":email",
"attempt-count": 0,
"max-attempts": 3,
"created-at": "2024-01-15T10:00:00.000Z",
"run-at": "2024-01-15T10:05:00.000Z",
"next-run": null,
"last-run": null,
"run-count": null,
"tags": [":email", ":welcome"],
"error": null
},
"payload": {
"user-id": 123,
"template": ":welcome"
}
}
GET /api/jobs/stats
Response:
{
"jobs": {
"by-status": {
":pending": 12,
":running": 2,
":completed": 156,
":failed": 3
},
"pending-by-queue": {
":default": 5,
":email": 4,
":reports": 3
},
"total": 173
},
"executions": {
"by-status": {
":success": {"count": 156, "avg-duration-ms": 245.5},
":failure": {"count": 3, "avg-duration-ms": 1523.2}
},
"total": 159
}
}
GET /api/jobs/due
Returns scheduled jobs that are ready for execution (run-at <= now).
Response:
{
"count": 3,
"jobs": [
{
"id": 17592186045600,
"name": "Send welcome email",
"handler": "myapp.jobs/send-email",
"status": ":pending",
"run-at": "2024-01-15T10:05:00.000Z",
...
}
]
}
GET /api/jobs/running
Response:
{
"count": 2,
"jobs": [
{
"id": 17592186045601,
"name": "Generate monthly report",
"handler": "myapp.jobs/generate-report",
"status": ":running",
"attempt-count": 1,
...
}
]
}
POST /api/jobs
Content-Type: application/edn
Request Body:
{:handler "myapp.jobs/send-email"
:payload {:user-id 123 :template :welcome}
:run-at "2024-01-15T10:05:00Z" ; ISO-8601 timestamp
:name "Send welcome email"
:priority 5
:queue :email
:max-attempts 3
:tags [:email :welcome]}
Or with delay instead of absolute time:
{:handler "myapp.jobs/send-reminder"
:payload {:user-id 123}
:delay-ms 300000 ; 5 minutes from now
:name "Send reminder"
:queue :email}
Response (201 Created):
{
"created": true,
"job": {
"id": 17592186045600,
"name": "Send welcome email",
"handler": "myapp.jobs/send-email",
"status": ":pending",
"priority": 5,
"queue": ":email",
"attempt-count": 0,
"max-attempts": 3,
"created-at": "2024-01-15T10:00:00.000Z",
"run-at": "2024-01-15T10:05:00.000Z",
"tags": [":email", ":welcome"],
"error": null
}
}
POST /api/jobs/triggered
Content-Type: application/edn
Triggered jobs execute in response to events.
Request Body:
{:handler "myapp.jobs/on-user-created"
:trigger-event :user/created
:payload {}
:name "Handle new user registration"
:queue :users}
Response (201 Created):
{
"created": true,
"job": {
"id": 17592186045610,
"name": "Handle new user registration",
"handler": "myapp.jobs/on-user-created",
"status": ":pending",
...
}
}
POST /api/jobs/recurring
Content-Type: application/edn
Recurring jobs run on a schedule.
Request Body (with cron):
{:handler "myapp.jobs/daily-backup"
:payload {:database "production"}
:cron "0 2 * * *" ; 2 AM daily
:name "Daily database backup"
:queue :maintenance
:max-runs 365}
Request Body (with interval):
{:handler "myapp.jobs/health-check"
:payload {}
:interval-ms 60000 ; Every minute
:name "Health check"
:queue :monitoring}
Response (201 Created):
{
"created": true,
"job": {
"id": 17592186045620,
"name": "Daily database backup",
"handler": "myapp.jobs/daily-backup",
"status": ":pending",
"next-run": "2024-01-16T02:00:00.000Z",
"run-count": 0,
...
}
}
POST /api/jobs/:id/cancel
Cancel a pending job. Only jobs in :pending status can be cancelled.
Example:
POST /api/jobs/17592186045600/cancel
Response:
{
"success": true,
"job": {
"id": 17592186045600,
"name": "Send welcome email",
"status": ":cancelled",
...
}
}
Error Response:
{
"error": "Can only cancel pending jobs",
"status": ":running"
}
POST /api/jobs/:id/pause
Pause a pending or running job.
Example:
POST /api/jobs/17592186045620/pause
Response:
{
"success": true,
"job": {
"id": 17592186045620,
"name": "Daily database backup",
"status": ":paused",
...
}
}
POST /api/jobs/:id/resume
Resume a paused job.
Example:
POST /api/jobs/17592186045620/resume
Response:
{
"success": true,
"job": {
"id": 17592186045620,
"name": "Daily database backup",
"status": ":pending",
...
}
}
POST /api/jobs/:id/execute
Execute a job immediately (for testing/debugging). Job must be in :pending status.
Example:
POST /api/jobs/17592186045600/execute
Response:
{
"executed": true,
"success": true,
"result": {"emails-sent": 1},
"error": null,
"job": {
"id": 17592186045600,
"name": "Send welcome email",
"status": ":completed",
...
}
}
Error Response (execution failed):
{
"executed": true,
"success": false,
"result": null,
"error": "SMTP connection refused",
"job": {
"id": 17592186045600,
"name": "Send welcome email",
"status": ":pending",
"attempt-count": 1,
"error": "SMTP connection refused",
...
}
}
All endpoints return standard HTTP status codes:
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Bad Request (invalid parameters) |
| 401 | Unauthorized (not authenticated) |
| 403 | Forbidden (insufficient permissions) |
| 404 | Not Found |
| 409 | Conflict (e.g., workflow already exists, invalid state transition) |
Error response format:
{
"error": "Description of the error",
"details": {...}
}
# 1. Create the workflow definition
curl -X POST http://localhost:8080/api/workflows \
-H "Content-Type: application/edn" \
-d '{:name :workflow/order
:states [{:name :order/pending :initial? true}
{:name :order/confirmed}
{:name :order/shipped}
{:name :order/delivered :terminal? true}]
:transitions [{:name :confirm :from :order/pending :to :order/confirmed}
{:name :ship :from :order/confirmed :to :order/shipped}
{:name :deliver :from :order/shipped :to :order/delivered}]}'
# 2. Start a process for an order
curl -X POST http://localhost:8080/api/processes \
-H "Content-Type: application/edn" \
-d '{:workflow :workflow/order
:subject 17592186045450
:data {:order-number "ORD-001"}}'
# 3. Check available transitions
curl http://localhost:8080/api/processes/17592186045500/transitions
# 4. Execute transitions
curl -X POST http://localhost:8080/api/processes/17592186045500/transition \
-H "Content-Type: application/edn" \
-d '{:transition :confirm}'
curl -X POST http://localhost:8080/api/processes/17592186045500/transition \
-H "Content-Type: application/edn" \
-d '{:transition :ship :reason "Handed to carrier"}'
curl -X POST http://localhost:8080/api/processes/17592186045500/transition \
-H "Content-Type: application/edn" \
-d '{:transition :deliver}'
# 5. View process history
curl http://localhost:8080/api/processes/17592186045500/history
# Schedule an email to be sent in 5 minutes
curl -X POST http://localhost:8080/api/jobs \
-H "Content-Type: application/edn" \
-d '{:handler "myapp.email/send-newsletter"
:payload {:campaign-id 42}
:delay-ms 300000
:name "Send newsletter"
:queue :email
:priority 10}'
# Check job status
curl http://localhost:8080/api/jobs/17592186045600
# View all pending email jobs
curl "http://localhost:8080/api/jobs?queue=email&status=pending"
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |