Automation API
REST endpoints for creating, managing, and executing automation flows, runs, scripts, and webhooks.
#Automation API
The automation system runs server-side flows in response to events, webhooks, schedules, or manual triggers. Flows consist of a trigger, a sequence of operations, and execution options.
All automation endpoints are mounted at /api/v1/automate/. The bare /api/automate/ alias is deprecated (ADR 0005).
#Permissions
| Action | Required for |
|---|---|
read |
List flows, runs, scripts, operations, email log, quotas |
create |
Create flows, scripts |
update |
Update flows, scripts, publish, set status |
delete |
Delete flows, runs, scripts |
write |
Cancel runs, cleanup stale runs |
manage |
Rollback versions, update quotas |
execute |
Execute flows, test flows, test scripts |
#Flow object
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"projectId": "proj_abc",
"name": "New Order Notification",
"description": "Send email when a new order is created",
"icon": "bolt",
"color": "#6750a4",
"status": "active",
"triggerType": "event_action",
"triggerConfig": { "events": ["record.create"] },
"operations": [
{
"key": "notify",
"type": "send_email",
"config": {
"to": "{{$trigger.body.email}}",
"subject": "Order Confirmed",
"body": "Thanks for your order!"
}
}
],
"options": {
"timeout_ms": 30000,
"max_retries": 0,
"concurrency": "sequential",
"error_handling": "stop_on_first",
"log_level": "full"
},
"collections": ["orders"],
"createdBy": "usr_01HXK5M9ABCDEF",
"runAs": "triggering_user",
"currentVersion": 3,
"publishedVersion": 2,
"lastRunAt": "2026-06-20T10:30:00.000Z",
"runCount": 42,
"errorCount": 1,
"createdAt": "2026-06-01T00:00:00.000Z",
"updatedAt": "2026-06-20T10:30:00.000Z"
}
#Flow statuses
| Status | Description |
|---|---|
draft |
Initial state. Use testFlow() to dry-run. Triggers do not fire. |
active |
Live. Triggers fire against the published version. |
inactive |
Paused. Triggers are ignored. Reactivate by setting status to active. |
#Flow CRUD
#List flows
GET /api/v1/automate/flows
Returns all flows in the current project.
#HTTP
GET /api/v1/automate/flows
Authorization: Bearer sk-your-api-key
#SDK
import { EmuView } from '@emuview/sdk';
const sdk = new EmuView({
url: 'https://your-api.example.com',
auth: { apiKey: 'sk-your-api-key' }
});
const { data: flows } = await sdk.automate.listFlows();
#Create flow
POST /api/v1/automate/flows
Create a new flow. Flows always start in draft status.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Flow name (1–200 characters) |
description |
string |
No | Human-readable description |
icon |
string |
No | Material icon name (default: bolt) |
color |
string |
No | Hex colour (default: #6750a4) |
triggerType |
string |
Yes | One of the trigger types |
triggerConfig |
object |
Yes | Trigger-specific configuration |
operations |
object[] |
Yes | Array of operation nodes |
collections |
string[] |
No | Collections in scope (default: ["*"]) |
runAs |
string |
No | Execution identity (default: triggering_user) |
options |
object |
No | Timeout, retries, concurrency, error handling |
#HTTP
POST /api/v1/automate/flows
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"name": "Welcome Email",
"triggerType": "event_action",
"triggerConfig": { "events": ["record.create"] },
"collections": ["users"],
"operations": [
{
"key": "send_welcome",
"type": "send_email",
"config": {
"to": "{{$trigger.body.email}}",
"subject": "Welcome to our app!",
"body": "Hello {{$trigger.body.name}}, welcome aboard!"
}
}
]
}
#SDK
const { data: flow } = await sdk.automate.createFlow({
name: 'Welcome Email',
trigger_type: 'event_action',
trigger_config: { events: ['record.create'] },
collections: ['users'],
operations: [
{
key: 'send_welcome',
type: 'send_email',
config: {
to: '{{$trigger.body.email}}',
subject: 'Welcome to our app!',
body: 'Hello {{$trigger.body.name}}, welcome aboard!'
}
}
]
});
#Response
201 Created — returns the full flow object in draft status.
#Get flow
GET /api/v1/automate/flows/:flowId
Returns flow detail including publishedOperations and webhookEndpoint (if applicable).
#Update flow
PATCH /api/v1/automate/flows/:flowId
Update the draft version. You can update name, operations, triggerConfig, options, collections, and runAs.
#HTTP
PATCH /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"operations": [
{
"key": "send_welcome",
"type": "send_email",
"config": {
"to": "{{$trigger.body.email}}",
"subject": "Welcome!",
"body": "Updated welcome message."
}
}
]
}
#SDK
await sdk.automate.updateFlow('550e8400-e29b-41d4-a716-446655440000', {
operations: [
{
key: 'send_welcome',
type: 'send_email',
config: {
to: '{{$trigger.body.email}}',
subject: 'Welcome!',
body: 'Updated welcome message.'
}
}
]
});
#Delete flow
DELETE /api/v1/automate/flows/:flowId
Delete a flow and all associated versions, runs, step logs, and webhooks.
#Duplicate flow
POST /api/v1/automate/flows/:flowId/duplicate
Create a copy of a flow in draft status.
POST /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/duplicate
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{ "name": "Welcome Email (Copy)" }
#Re-importing an exported flow
GET /api/v1/automate/flows/:flowId/export emits a portable document that carries the flow's identity — its slug, which is unique within the Project. Sending that document straight back to POST /api/v1/automate/flows/import therefore asks for a second flow at a slug the Project already uses, and is refused with 409. Changing name alone does not help: the name is not the identity.
Choose the one you meant:
- Update the flow you exported —
POST /api/v1/automate/flows/import?flowId=<id>. The document replaces that flow's draft; the flow keeps its id, version history, run counters, and webhook token. - Make a copy — omit
slugfrom the imported body, and a fresh one is derived from the newnameand numbered past whatever the Project already holds.POST /api/v1/automate/flows/:flowId/duplicatedoes the same thing in a single call.
Importing into a different Project needs neither adjustment — a slug is unique only within its own Project, so the exported one is free there.
#Credentials in a flow's config are masked
A step's config can hold a credential — a webhook_dispatch signing secret, an http_request bearer token or basic password, an Authorization header on any of them — and so can a webhook trigger's secret. Every read of a flow replaces those values with ••••••••:
GET /api/v1/automate/flowsGET /api/v1/automate/flows/:flowId, including itspublishedOperationsGET /api/v1/automate/flows/:flowId/versions/:versionGET /api/v1/automate/runs/:runId, where a step's recordedinputis its resolved config
A caller holding system/automate:update sees the real values on the first three, because a principal who can edit a flow can already point it at a URL of their own and read the credential off the delivery. system/automate:read alone never does — the same rule webhookEndpoint.token has always followed.
A header name survives the mask and its value does not: a header appearing is a change you should be able to see, and a name is not a credential.
GET /api/v1/automate/flows/:flowId/export masks for everyone, update grant or not. The document is portable — it is what you paste into a Blueprint and what the install ledger canonicalises — so it carries no credential at all.
Writing one back. A masked value means unchanged. POST /api/v1/automate/flows, PATCH /api/v1/automate/flows/:flowId and POST /api/v1/automate/flows/import all restore •••••••• from the value already stored on that flow, matched by step key, so a read-modify-write round trip does not overwrite a credential with the mask. When there is nothing stored to restore — a new flow, a new step, an exported document imported somewhere else — the key is dropped instead, so the flow lands unsigned rather than signing with the mask. Send a real value to set or rotate one.
#Set flow status
PATCH /api/v1/automate/flows/:flowId/status
Change the flow status. Setting to active requires a published version.
PATCH /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/status
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{ "status": "active" }
#Versioning
Every publish creates an immutable snapshot. Only the published version runs in production.
| Method | Path | Permission | Description |
|---|---|---|---|
POST |
/flows/:flowId/publish |
update | Publish draft as immutable version |
GET |
/flows/:flowId/versions |
read | List versions (newest first) |
GET |
/flows/:flowId/versions/:ver |
read | Get version snapshot |
POST |
/flows/:flowId/rollback/:ver |
manage | Rollback to version (overwrites draft and published) |
#Publish
POST /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/publish
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{ "changeSummary": "Added retry logic to email step" }
#SDK
const result = await sdk.automate.publishFlow(
'550e8400-e29b-41d4-a716-446655440000',
'Added retry logic to email step'
);
console.log(`Published version ${result.data.version}`);
#Execution
#Execute flow
POST /api/v1/automate/flows/:flowId/execute
Manually trigger a flow. Runs asynchronously — returns immediately with a runId.
#HTTP
POST /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/execute
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"customer_id": "cust_123",
"action": "generate_report"
}
#SDK
const { data: result } = await sdk.automate.executeFlow('550e8400-e29b-41d4-a716-446655440000', {
customer_id: 'cust_123',
action: 'generate_report'
});
console.log(`Run started: ${result.runId}`);
#Response
202 Accepted
{
"data": {
"runId": "run_a1b2c3d4e5f6",
"flowId": "550e8400-e29b-41d4-a716-446655440000",
"status": "running",
"startedAt": "2026-06-30T01:30:00.000Z"
}
}
#Test flow
POST /api/v1/automate/flows/:flowId/test
Dry-run the flow synchronously using the draft operations. Returns a step-by-step trace.
#Response
200 OK
{
"data": {
"runId": "test-a1b2c3d4",
"status": "completed",
"dryRun": true,
"steps": [
{
"key": "send_welcome",
"type": "send_email",
"status": "completed",
"output": { "messageId": "msg_test_001" },
"durationMs": 12
}
],
"logs": []
}
}
#Runs
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/runs?page=1&limit=25&status=failed&flow_id=abc |
read | List all runs (paginated, filterable) |
GET |
/flows/:flowId/runs |
read | List runs for a specific flow |
GET |
/runs/:runId |
read | Run detail with stepLogs array |
DELETE |
/runs/:runId |
delete | Delete run and step logs |
POST |
/runs/:runId/cancel |
write | Cancel a stuck run (pending/running) |
POST |
/runs/cleanup-stale |
write | Reap orphaned runs stuck >2 min |
#Run statuses
| Status | Description |
|---|---|
pending |
Queued, waiting to execute |
running |
Currently executing |
completed |
Finished without errors |
failed |
One or more steps failed |
cancelled |
Cancelled by user or system |
timed_out |
Exceeded timeout_ms |
#Step statuses
pending, running, completed, failed, skipped
#Cancel a run
POST /api/v1/automate/runs/:runId/cancel
Cancels a run that is stuck in pending or running state. The run is marked as cancelled and frees up a concurrency slot. Only works on runs that haven't reached a terminal status.
#HTTP
POST /api/v1/automate/runs/run_a1b2c3d4e5f6/cancel
Authorization: Bearer sk-your-api-key
#SDK
await sdk.automate.cancelRun('run_a1b2c3d4e5f6');
#Response
200 OK
{ "success": true }
Returns 404 if the run was not found or already in a terminal status.
#Cleanup stale runs
POST /api/v1/automate/runs/cleanup-stale
Reaps orphaned runs stuck in pending or running state. Any run active longer than 2 minutes is marked as failed with an "orphaned" error. This also runs automatically before each flow execution and on the hourly cron trigger, but you can call it manually to force cleanup.
#HTTP
POST /api/v1/automate/runs/cleanup-stale
Authorization: Bearer sk-your-api-key
#SDK
const { reaped } = await sdk.automate.cleanupStaleRuns();
if (reaped > 0) {
console.log(`Cleaned up ${reaped} orphaned runs`);
}
#Response
200 OK
{ "success": true, "reaped": 3 }
Stale run cleanup runs automatically in three scenarios:
- Before each flow execution — prevents orphaned runs from blocking the concurrency limit.
- Hourly cron trigger — sweeps all projects on the
0 * * * *schedule. This depends on the instance's maintenance tick actually running; if it has stalled, only scenarios 1 and 3 apply.- Manual API call — use this endpoint for on-demand cleanup.
#Trigger types
| Type | Blocking? | Config |
|---|---|---|
event_action |
No (waitUntil) |
{ events: ["record.create", "record.update", "record.delete"] } |
event_filter |
Yes (can abort/modify data) | { events: ["record.create"] } |
webhook |
No (202) | { require_auth?, allowed_methods?, ip_allowlist?, secret? } |
schedule |
No (DO alarm) | { cron: "0 0 * * *", timezone?: "Australia/Sydney" } |
manual |
No (202) | { manual_type, collections?, selection?, input_fields?, confirm? } |
api_endpoint |
No (202) | { method?, require_auth? } |
another_flow |
Depends on parent | {} (data passed from parent trigger_flow operation) |
app_event |
No (waitUntil) |
{ events: ["user.login", "file.upload"] } |
Schedule cron expressions are evaluated in the trigger's timezone (an IANA name such as Australia/Sydney), including daylight-saving transitions. When timezone is omitted, schedules are evaluated in UTC. An invalid timezone is rejected with a 422 when the flow is activated.
#Webhooks (public)
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/webhook/:token |
None (unless require_auth) |
Trigger flow from external service |
GET |
/webhook/:token |
None (unless require_auth) |
Trigger flow with query params as data |
#Response
202 Accepted
{ "success": true, "runId": "run_a1b2c3d4", "message": "Flow triggered." }
When require_auth is enabled, pass the webhook secret in the Authorization header:
POST /api/v1/automate/webhook/tok_a1b2c3d4
Authorization: Bearer webhook-secret-value
Content-Type: application/json
{ "order_id": "ord_12345" }
#Flow run_as
The run_as field controls the identity used when a flow executes:
| Value | Behaviour |
|---|---|
triggering_user (default) |
Runs as the user who triggered it. Webhooks fall back to the least-privilege webhook role; scheduled runs fall back to the flow owner |
flow_owner |
Runs as the flow's creator, regardless of who triggered it |
role:<roleId> |
Runs as the triggering identity with the specified role's permissions |
Use GET /api/v1/automate/assignable-roles to list roles you can delegate.
New flows always start as triggering_user — the create endpoint does not accept run_as; change it afterwards via PATCH /api/v1/automate/flows/:flowId. The flow settings UI offers only triggering_user and flow_owner; role:<roleId> values are set via the API/SDK.
Setting run_as to anything other than triggering_user requires manage on system/automate, and the delegated identity's permissions must be a subset of your own — otherwise the update returns 403 forbidden. That includes flow_owner when the owner's role outranks yours.
#Template variables
Operation config values support {{expression}} syntax, resolved at runtime:
| Expression | Value |
|---|---|
{{$trigger.body.fieldName}} |
Field from trigger payload |
{{$trigger.event}} |
Event name (e.g., record.create) |
{{$trigger.collection}} |
Collection name |
{{$steps.stepKey.field}} |
Output from a previous step |
{{$user.id}} |
Current user ID |
{{$user.email}} |
Current user email |
{{$flow.id}} |
Flow ID |
{{$run.id}} |
Current run ID |
{{$now}} |
ISO 8601 timestamp |
{{= expression }} |
Evaluate a JavaScript expression |
#Operations catalog
GET /api/v1/automate/operations returns all registered operations with type, name, category, and form fields.
| Category | Operations |
|---|---|
| Data | crud_action, crud_create, crud_read, crud_update, crud_delete, crud_query, build_search_index |
| Logic | condition, switch, loop, stop, throw_error, set_variable, trigger_flow |
| Transform | transform, json_parse, text_format, math, datetime, log, delay |
| AI | ai_generate, ai_classify, ai_extract, ai_summarize, ai_translate, ai_review, ai_image |
| Files | file_read, file_upload, file_transform, s3_action |
| Network | http_request, graphql_request, webhook_dispatch |
| Script | run_script, run_expression |
| Communication | send_email, send_notification |
| System | system_archival, system_limit_check, system_monthly_summary (system flows only) |
Use GET /api/v1/automate/operations/:type for the full config schema of any operation.
#Scripts CRUD
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/scripts |
read | List saved scripts |
POST |
/scripts |
create | Create script |
PATCH |
/scripts/:scriptId |
update | Update script |
DELETE |
/scripts/:scriptId |
delete | Delete script |
POST |
/scripts/:scriptId/test |
execute | Test script with mock data |
#Quotas
GET /api/v1/automate/quotas returns project quotas and current usage.
Default quotas:
| Limit | Default |
|---|---|
| Max flows | 50 |
| Max concurrent runs | 5 |
| Max execution time | 30,000 ms |
| Max runs per hour | 500 |
| Max AI tokens per day | 50,000 |
| Max emails per day | 200 |
| Max script CPU time | 5,000 ms |
Admins can update quotas with PATCH /api/v1/automate/quotas (requires manage permission).