Skip to content
Sendora Cloud
Create account
Module deep-dive

Automation / Workflows

Event-triggered DAGs across email / push / SMS / webhook / branch / wait / ai_action / update_profile. Build in the dashboard visual editor or define via API; export as a versioned bundle for git + CI promotion.

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 to traits.<saveTo> as a string.
  • decide — categorical decision. Set jsonMode: true + describe the enum in the prompt. Returns { choice: string, confidence: number }.
  • extract — structured extraction. Set jsonMode: true + describe the schema in the prompt. Returns the parsed JSON object; merged into traits.<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-send variantKey stamping 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_jsonjsonMode: 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.json

VS 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