Skip to Content
AutomateWorkflows

Workflows

The workflow system provides state machine automation for any collection. Workflows control how items move between states (e.g., draft → review → published), who can trigger each transition, and what side effects run automatically.

Concepts

ConceptDescription
Workflow DefinitionJSON state machine stored in daas_wf_definition
Workflow AssignmentLinks a workflow to a collection + optional filter rule
Workflow InstanceCreated automatically when a matching item is created
TransitionMoving from one state to another via a named command
ActionAn event emitted after a successful transition

Workflow Definition Structure

{ "initial_state": "Draft", "states": [ { "name": "Draft", "commands": [ { "name": "Submit", "next_state": "Pending Approval", "policies": [], "actions": [] } ], "isEndState": false }, { "name": "Pending Approval", "commands": [ { "name": "Reject", "next_state": "Draft", "policies": [], "actions": [] }, { "name": "Approve", "next_state": "Published", "policies": ["550e8400-e29b-41d4-a716-446655440000"], "actions": [ { "name": "Publish", "event_name": "xtr.item.promote", "parameters": {} } ] } ], "isEndState": false }, { "name": "Published", "commands": [], "isEndState": true } ] }

State fields

FieldTypeDescription
namestringUnique state identifier
commandsarrayAvailable transitions from this state
isEndStatebooleanTerminal state — no further transitions

Command fields

FieldTypeDescription
namestringUnique command identifier
next_statestringState to transition to
policiesstring[]Required policy UUIDs — user must hold at least one
module_access_keysstring[]Optional module access keys — user must hold at least one; OR-merged with policies
actionsarrayEvents to emit after transition

The workflow:approve and workflow:reject keys are seeded in the Module Access Keys registry, so Approve/Reject commands can be gated with a key grant (a policy’s Module-Level Access tab) without creating dedicated policies. See Module Access for enforcing the same keys in your own routes.

Action fields

FieldTypeDescription
namestringHuman-readable label
event_namestringEvent emitted — xtr.item.promote or custom
parametersobjectExtra data passed to the event handler

Automatic Instance Creation

When an item is created in a collection that has a Workflow Assignment, the system automatically:

  1. Checks daas_wf_assignment for matching assignments (by collection + filter rule)
  2. Creates a daas_wf_instance with current_state set to initial_state
  3. Updates the item’s workflow instance field and state field

Exactly one assignment must match per item. If zero match, the workflow is skipped. If multiple match, an error is logged and no instance is created.

Required collection fields

Two fields must exist in the collection: a Many-to-One link to daas_wf_instance and a Workflow State string field.

New collections — enable both switches in the collection creation wizard under System Fields → Workflow Fields:

SwitchField createdDetails
Workflow Instanceworkflow_instanceM2O to daas_wf_instance, nullable UUID
Workflow Stateworkflow_statextr-interface-workflow interface, read-only

Existing collections — add the fields manually via Data Model → [Your Collection] → New Field:

Field typeInterfacePurpose
Many-to-One → daas_wf_instanceM2OStores the workflow instance ID
Stringxtr-interface-workflow (Workflow State)Stores the current state name

Mark both as read-only in the field metadata — users should not edit them directly.

Execute a Transition

POST /api/workflow/transition
{ "workflowInstanceId": "uuid", "commandName": "Approve" }

Success (200):

{ "message": "Successfully transitioned workflow state" }

Unauthorized (403):

{ "message": "You are not authorized to perform this transition" }

The endpoint:

  1. Validates the command exists in the current state
  2. Checks the user holds at least one required policy UUID (directly or via role) or one of the command’s module_access_keys (granted via a policy’s Module-Level Access tab) — the two lists are OR’d
  3. Updates current_state in daas_wf_instance
  4. Records the transition in daas_wf_history
  5. Updates the workflow state field in the target collection or version
  6. Fires all configured actions in sequence

Locking Commands to Roles

policies entries must be policy UUIDs (daas_policies.id) — not the id of the daas_access row that links a policy to a role. The authorization check resolves the caller’s held policies via the get_user_policies database function and compares that list directly against command.policies; daas_access is only the join table connecting a policy to a role or user, and its own id is never checked against anything.

// 1. Create a policy POST /api/policies { "name": "Article Reviewer", "description": "Can approve/reject articles" } // → { "data": { "id": "3f1e...-policy-id", ... } } // 2. Link the policy to a role POST /api/access { "role": "editor-role-uuid", "policy": "3f1e...-policy-id" } // 3. Reference the POLICY id (not the daas_access row's id) in the command { "name": "Approve", "next_state": "Published", "policies": ["3f1e...-policy-id"], "actions": [] }

Using the daas_access row’s own id here instead of the policy’s id is a common mistake. It produces a workflow that looks fully configured — valid UUID, saves without error — but silently returns 403 for every user who should be authorized, since that UUID never matches anything get_user_policies returns. Always copy the id from the policy, not from the access-junction record.

If your client renders available commands conditionally (e.g. hiding a button the current user isn’t authorized to click), it needs its own way to resolve the caller’s policy UUIDs — GET /api/users/me does not include a policies field. Use GET /api/policies/me, which returns the policy records effective for the authenticated user at the current scope (send X-Resource-Uri when scoping applies). The transition endpoint enforces authorization regardless of what the client displays, so a stale or missing client-side check is a UX gap, not a security gap — but it will make an otherwise-correct role gate look “broken” during testing when it’s really just uninformed.

Scope-Aware Authorization

If the request carries an X-Resource-Uri header, the policy and module-key checks resolve at that scope: only policies granted at the asserted scope (or an ancestor, including root) authorize the transition. An invalid or unauthorized Resource URI fails the request — it does not fall back to the scope-blind check. Requests without the header keep the legacy flat resolution across all scopes.

Built-in Actions

xtr.item.promote

Promotes a content version to the main item. Only runs when the workflow instance is linked to a version (version_key is not null).

{ "name": "Promote to Main", "event_name": "xtr.item.promote", "parameters": {} }

Custom actions

Register any event_name in a file-based extension or runtime extension:

export function register(sdk) { sdk.emitter.onAction('xtr.notification.send-email', async (meta, context) => { // action parameters are spread onto meta.payload[0] alongside workflow_instance const { recipient, workflow_instance } = meta.payload[0]; await sendEmail(recipient, 'Workflow state changed'); }); }

Workflow Assignments

Assignments connect a workflow definition to a collection with an optional filter rule:

{ "collection": "articles", "workflow": "workflow-definition-uuid", "filter_rule": { "category": { "_eq": "news" } } }

An empty filter_rule matches all items in the collection.

Version Workflows

Workflows can track content versions. When a version is created:

  • The filter rule is tested against the parent item (not the version itself)
  • The instance is linked with a version_key
  • The xtr.item.promote action applies the version delta to the main item

Best Practices

  1. One assignment per collection/filter combination — avoid overlapping filter rules
  2. Mark workflow fields as read-only — users should not manually edit workflow_instance or workflow_state
  3. Use empty policies arrays for commands that any authenticated user can trigger
  4. Test filter rules with Supabase queries before production
  5. When gating a command, always use the policy’s own id — see Locking Commands to Roles. Copying the daas_access row’s id instead is a common mistake that silently locks everyone out.

REST API

MethodPathDescription
GET/api/workflowsList workflow definitions
POST/api/workflowsCreate workflow definition
GET/api/workflows/:idGet workflow definition
PATCH/api/workflows/:idUpdate definition
DELETE/api/workflows/:idDelete definition
GET/api/workflow-instancesList instances
GET/api/workflow-instances/:idGet instance
GET/api/workflow-instances/:id/historyList transition history for an instance
GET/api/workflow-assignmentsList assignments
POST/api/workflow-assignmentsCreate assignment
GET/api/workflow-assignments/:idGet assignment
PATCH/api/workflow-assignments/:idUpdate assignment
DELETE/api/workflow-assignments/:idDelete assignment
POST/api/workflow/transitionExecute transition
Last updated on