Model
A workflow has one trigger and a list of steps. Trigger matches an event by name + optional property filters + optional audience membership. Steps execute in order; branch can jump to labels; wait persists state to workflow_runs.next_step_at and resumes when the poller fires (every 30s).
Workflows are per-project (ADR-013). The event's projectId scopes which workflows fire. An event with no projectId falls through to org-wide workflows so legacy ingest paths keep working.
Trigger
{
"eventType": "checkout_completed",
"filters": { "currency": "USD", "channel": "web" },
"audienceId": "<AUDIENCE_UUID>"
}eventType— required. Literal match (no wildcards).filters— optional. Map of property path → required string. AND-combined with eventType.audienceId— optional. Restrict to users in this audience at event time.
Step types
Each step has type + config (+ optionaldelayMinutes). Templates use {{handlebars}} against trigger context ({{event.*}}, {{user.*}}, {{trait.*}}).
send_email { templateId, to } OR { subject, bodyHtml, to }
BYOD verified domain required.
send_push { title, body }
fans out to ctx.userId's active push tokens.
send_sms { to, body }
Twilio configured required (returns 412 otherwise).
update_profile { traits: { ... } }
merges into ctx.user profile.
webhook { url, method?, headers?, body? }
HMAC-signed, SSRF-guarded, retried with backoff.
branch { condition: { trait/event, op, value }, ifTrue, ifFalse }
jumps to a labeled step.
wait { reason? } + delayMinutes
persists next_step_at; poller resumes.
ai_action { flavor: "generate"|"decide"|"extract", prompt, saveTo? }
Ollama Cloud; jsonMode optional; saveTo merges into traits.Step config schemas (full)
send_email
{
"type": "send_email",
"config": {
"templateId": "<TEMPLATE_UUID>", // OR
"subject": "Welcome {{user.firstName}}",
"bodyHtml": "<p>Hi {{user.firstName}}</p>",
"to": "{{user.email}}",
"category": "transactional" // or "workflow"
}
}BYOD verified domain required. Inline (subject + bodyHtml) wins over templateId when both present.
send_push
{
"type": "send_push",
"config": {
"title": "{{event.team}} scored",
"body": "{{event.player}} — {{event.score}}",
"data": { "matchId": "{{event.matchId}}", "url": "/match/{{event.matchId}}" },
"actions": [{ "id": "watch", "title": "Watch" }],
"templateId": "<TEMPLATE_UUID>",
"abTestId": "<TEST_UUID>",
"criticalAlert": { "soundName": "siren.caf", "volume": 1.0 }
}
}Targets ctx.userId (set by trigger event's userId). Fans out to all that user's isActive=true tokens. Same body shape as direct POST /push/send minus the recipient fields.
send_sms
{
"type": "send_sms",
"config": {
"to": "{{user.phone}}",
"body": "Your code is {{event.code}}"
}
}Twilio configured required (returns 412 + step outcome error otherwise).
update_profile
{
"type": "update_profile",
"config": {
"traits": {
"lastEventAt": "{{event.timestamp}}",
"matchesWatched": "+1" // increment shorthand
},
"merge": "shallow" // "shallow" | "deep" | "replace"
}
}Default merge: shallow (last-write-wins per top- level key). deep recurses into nested objects; arrays union by value. replace overwrites the entire traits map. Increment shorthand: "+1" / "-2.5" on a number trait coerces to atomic increment.
webhook
{
"type": "webhook",
"config": {
"url": "https://your-app.com/hook",
"method": "POST",
"headers": { "X-Custom": "value" },
"body": { "userId": "{{event.userId}}", "amount": "{{event.amount}}" }
}
}Body content-type: object → JSON (Content-Type: application/json); string → raw (text/plain); URL-encoded form via explicit headers: {"Content-Type": "application/x-www-form-urlencoded"} + string body.
Handlebars in headers + body: every value in headers + body goes through the same interpolator as templates. Use {{event.*}} / {{user.*}} / {{trait.*}} in either. Common pattern: Authorization: Bearer {{trait.api_token}} on workflows that fan out to per-user external services. Keys are not interpolated — only values.
HMAC secret: per-workflow, auto-generated at workflow create. View via GET /orgs/:orgId/automation/workflows/:id (webhookSecret field). Rotate via POST /orgs/:orgId/automation/workflows/:id/rotate-webhook-secret — old secret valid 24h after rotation. Header is X-Sendora-Signature (HMAC-SHA256 hex of raw body).
SSRF-guarded (RFC1918 + cloud-metadata blocked). Workflow webhook step uses the same retry policy as subscription webhooks — 30s / 2m / 10m / 1h / 6h schedule, no-retry on 4xx (except 408 / 429), idempotency by event id. See /docs/webhooks → retry policy.
branch
{
"type": "branch",
"config": {
"condition": {
"trait": "plan", // or "event": "<property>"
"op": "eq", // see operator list below
"value": "pro"
},
"ifTrue": "step-3-label", // jumps to step with matching label
"ifFalse": "step-7-label"
}
}Operators: eq, neq, gt, gte, lt, lte, contains, not_contains, starts_with, ends_with, in, not_in, set, not_set. Right-hand value coerced to the trait's runtime type. Steps reference each other by string label set in step.label.
wait
{
"type": "wait",
"config": { "reason": "let user finish onboarding" },
"delayMinutes": 1440
}delayMinutes is the wait duration; min 0, max 525600 (1 year). 30s poller resumes the run when workflow_runs.next_step_at <= now() — actual wake latency is cron-poll bound (≤ 30s past target).
ai_action
{
"type": "ai_action",
"config": {
"flavor": "generate", // "generate" | "decide" | "extract"
"prompt": "Write a 1-line welcome for a {{user.plan}} user named {{user.firstName}}",
"jsonMode": false, // true forces JSON output
"saveTo": "welcomeMessage" // saves output to traits.<saveTo>
}
}Modes:
generate— free-form text. Returns{ text: string }; saved totraits.<saveTo>as a string.decide— categorical decision. SetjsonMode: true+ describe the enum in the prompt. Returns{ choice: string, confidence: number }.extract— structured extraction. SetjsonMode: true+ describe the schema in the prompt. Returns the parsed JSON object; merged intotraits.<saveTo>.
Quota economics: /docs/api → ai_action quota. Free / Starter return outcome ai_unavailable; Growth+ uses Sendora-paid Ollama Cloud (100K tokens/org/day); BYOK routes through your own Ollama Cloud key.
Trigger filter expressions
trigger.filters is a flat map of property path → required string value, AND-combined with eventType. Exact match only in v1; for richer expressivity (gt / contains / regex), put a branch step at the top of the workflow.
{
"trigger": {
"eventType": "checkout_completed",
"filters": {
"currency": "USD",
"channel": "web"
}
}
}A/B test `status` enum
draft— new, not yet receiving traffic.active— bucketing live; sends pick variants.paused— accept new sends but route 100% to fallback (no variant assignment).completed— winner declared; per-sendvariantKeystamping stops.
Step outcomes — full enum
Each step's run row stamps an outcome. Inspect via GET /automation/runs/:runId:
executed— non-dispatch step ran (wait, branch, update_profile).sent— dispatch step succeeded (send_email / send_push / send_sms / webhook 2xx).suppressed— recipient on suppression list / over freq cap / quiet hours.byod_required— workflow email step blocked because no verified custom sending domain.rate_limited— burst cap reached; step retries on next workflow run.error— exception or non-2xx response surfaced to run detail.skipped-missing-config— required config field absent.skipped-unknown-type— step type not in the supported enum (forward-compat for old bundles imported into newer runtimes).
AI-specific outcomes (`ai_action` step only)
ai_unavailable— plan doesn't include AI (Free / Starter), or Growth+ org exceeded daily quota, or Ollama Cloud upstream returned 5xx after retries.ai_pending— long-running model call still in flight; workflow re-evaluates the step on the next poller tick.ai_invalid_json—jsonMode: true+ model returned non-parseable JSON. Retried once with stricter system prompt; persists if retry also fails.ai_quota_exceeded— BYOK token quota exhausted at upstream Ollama Cloud (HTTP 429 from upstream).
Export / import for IaC
Bundle format version: 1 at sendoracloud.com/schemas/workflow-bundle.v1.json. Strips org / project IDs + per-instance metadata so the same bundle deploys to dev / staging / prod. Importer is idempotent by name — re-importing updates in place.
# Export single workflow
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/<WORKFLOW_UUID>/export \
-H "x-api-key: sk_prod_..." > welcome.workflow.json
# Export every workflow in a project
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/export?projectId=<PROJECT_UUID>' \
-H "x-api-key: sk_prod_..." > all.workflows.json
# Import (idempotent — same name updates, new name creates)
curl -X POST 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/import?projectId=<PROJECT_UUID>' \
-H "x-api-key: sk_prod_..." \
-H "Content-Type: application/json" \
-d @welcome.workflow.jsonVS Code + IntelliJ auto-fetch the schema via the bundle's $schema field, giving autocomplete + validation in committed .workflows.json files.
Dry-run
Test what a workflow would do without writing rows or firing side effects. Returns a step-by-step trace.
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/automation/workflows/<WORKFLOW_UUID>/test-run \
-H "x-api-key: sk_prod_..." \
-d '{ "eventType": "checkout_completed", "properties": { "amount": 49.99 } }'More
- API reference for workflows.
- Bundle format + IaC walkthrough.
- Push deep-dive (workflows fire push via
send_pushstep).