Workflow patterns

A cookbook of proven workflow shapes, each with a diagram, a JSON snippet and its pitfalls. Covers find or create, guarding update triggers with changes, branching on lookups, regex keyword tagging, calling your own API, callable workflows, scheduled follow-ups instead of loops, idempotent steps for retries, and copying workflows between orgs.

Most workflows are built from a handful of shapes. This page collects the ones that hold up in production, with the mistakes each one avoids. Every snippet is valid workflow JSON; the step IDs are readable on purpose, and the tag, workflow and variable IDs are placeholders for your org's own.

Two habits make every pattern work better:

  • Keep each workflow small: one trigger, one or two checks, one outcome. Split bigger flows into a caller and callable workflows.
  • Create workflows disabled, add their variables, test every path, then enable them. See Write a workflow in JSON.
PatternUse it when
Find or createAn event identifies a record that may not exist yet
Guard update triggers with changesYou use any *.update trigger
Branch on lookup resultsThe decision depends on data the event doesn't carry
Tag on a keyword with a regexYou need to find a pattern in text
Call your own API and handle the responseThe logic lives in your service
Reuse logic with a callable workflowSeveral workflows, or an AI agent, need the same steps
Schedule follow-ups instead of loopingSomething should happen later, or be checked again
Make steps safe to repeatA workflow creates records or calls other systems
Copy a workflow to another orgYou promote workflows from a sandbox to production

Find or create

Use it when an event carries an identifier, such as an email or an external ID, and you need the matching record whether or not it exists yet. Kustomer's own email integration uses this shape to match each sender to a customer.

The lookup and its two branches
{
  "id": "find-customer",
  "action": "kustomer.customer.find-by-email",
  "params": { "email": "/#steps.1.attributes.data.email" },
  "transitions": [
    {
      "target": "record-order",
      "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] },
      "meta": { "name": "Customer found" }
    },
    {
      "target": "create-customer",
      "condition": { "op": "dne", "values": ["/#steps.find-customer.id"] },
      "meta": { "name": "No customer yet" }
    }
  ]
}

Watch out for:

  • A lookup that finds nothing succeeds with empty output, so always branch on exists and dne of its id. Without the check, the next step runs with an empty ID.
  • Branches don't merge back. Give each branch its own final step, even if it repeats another one.
  • Prefer a stable identifier. Emails change. If your system has its own ID, look up by it (kustomer.customer.find-by-external-id) and store it on the record you create.
  • Retries can repeat the create. See Make steps safe to repeat.

The JSON tutorial builds this workflow from scratch.

Guard update triggers with changes

Use it when you use any *.update trigger. They fire on every change to the record, including every new message on a conversation and every change your own workflow makes. A condition on the record's current state matches again and again; a condition on changes matches only the update that made the change.

The right-hand path isn't a transition: when no transition matches, the run simply ends.
Trigger with a changes guard and a marker
{
  "id": "1",
  "eventName": "kustomer.conversation.update",
  "transitions": [
    {
      "target": "tag-resolved",
      "condition": {
        "op": "and",
        "values": [
          { "op": "eq", "values": ["/#steps.1.changes.attributes.status.after", "done"] },
          { "op": "not-contains", "values": ["/#steps.1.tags", "64f1c0ffee5ca1ab1e000003"] }
        ]
      },
      "meta": { "name": "Just closed, not yet tagged" }
    }
  ]
}

The second condition is an idempotency marker: tag-resolved adds tag 64f1c0ffee5ca1ab1e000003, so the workflow can never act twice on the same conversation, even if it's reopened and closed again. Drop it if you want that.

Watch out for:

  • Gate on the change, not the state. /#steps.1.changes.attributes.status.after is set only when this update changed the status. /#steps.1.status is done for every later update too.
  • Custom fields change as one custom entry, so the path is /#steps.1.changes.attributes.custom.after.<field>. See Conditions on what changed.
  • Skip records you didn't mean to handle. For message triggers, add dne on /#steps.1.importedAt and neq of /#steps.1.source and "bulk". To skip messages written by bots, look up the author and check neq of their userType and "machine".
  • Mark the text you post. A workflow that writes messages or notes should include a marker such as [auto] and check not-contains on the preview, so it doesn't answer itself.
  • neq passes when the field is missing. Pair it with exists when that matters.

Branch on lookup results

Use it when the decision depends on data the event doesn't carry, such as a field on the customer when a conversation is created. Look the record up, then branch on its output.

Trigger and lookup
{
  "trigger": {
    "id": "1",
    "eventName": "kustomer.conversation.create",
    "transitions": [
      { "target": "lookup-customer", "condition": { "op": "exists", "values": ["/#steps.1.customer"] } }
    ]
  },
  "steps": [
    {
      "id": "lookup-customer",
      "action": "kustomer.customer.find",
      "params": { "identifier": "/#steps.1.customer" },
      "transitions": [
        {
          "target": "tag-vip",
          "condition": { "op": "eq", "values": ["/#steps.lookup-customer.custom.vipBool", true] },
          "meta": { "name": "Customer is VIP" }
        },
        {
          "target": "tag-gold",
          "condition": { "op": "eq", "values": ["/#steps.lookup-customer.custom.tierStr", "gold"] },
          "meta": { "name": "Gold tier" }
        }
      ]
    },
    {
      "id": "tag-vip",
      "action": "kustomer.conversation.append-tags",
      "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000002"] },
      "transitions": []
    },
    {
      "id": "tag-gold",
      "action": "kustomer.conversation.append-tags",
      "params": { "id": "/#steps.1.id", "tags": ["64f1c0ffee5ca1ab1e000005"] },
      "transitions": []
    }
  ]
}

Watch out for:

  • The first match wins. Put the most specific branch first. To apply both tags, chain the checks instead: VIP check, VIP tag, then a gold check after it.
  • Guard the lookup. The exists check on the trigger skips the lookup when a conversation has no customer.
  • Put the reference first in eq. The right side is cast to the left side's type, so ["/#steps.lookup-customer.custom.vipBool", true] compares booleans. When the field is missing, no cast happens and eq is false.
  • Tags, users and teams are IDs, never names.
  • Use append-tags rather than conversation.update with tags, which replaces the whole list.

Tag on a keyword with a regex

Use it when you need to find a pattern in text: whole words, alternatives, or values such as order numbers. For a single plain keyword, a contains condition is simpler (see the tip below).

Trigger, regex step and tag
{
  "trigger": {
    "id": "1",
    "eventName": "kustomer.message.create",
    "transitions": [
      {
        "target": "match-refund",
        "condition": {
          "op": "and",
          "values": [
            { "op": "eq", "values": ["/#steps.1.channel", "email"] },
            { "op": "eq", "values": ["/#steps.1.directionType", "initial-in"] },
            { "op": "exists", "values": ["/#steps.1.preview"] }
          ]
        },
        "meta": { "name": "First inbound email" }
      }
    ]
  },
  "steps": [
    {
      "id": "match-refund",
      "action": "kustomer.regex-match.generic",
      "params": { "testString": "/#steps.1.preview", "regex": "\\b(refund|chargeback|money back)\\b", "flags": "i" },
      "transitions": [
        {
          "target": "tag-refund",
          "condition": { "op": "exists", "values": ["/#steps.match-refund.match"] },
          "meta": { "name": "Mentions a refund" }
        }
      ]
    },
    {
      "id": "tag-refund",
      "action": "kustomer.conversation.append-tags",
      "params": { "id": "/#steps.1.conversation", "tags": ["64f1c0ffee5ca1ab1e000004"] },
      "transitions": []
    }
  ]
}

The regex step's output is { match, captures, index, original }. Read the first capture group at /#steps.match-refund.captures.0.

Watch out for:

  • Pass flags in flags. Inline flags such as (?i) are a syntax error and fail the step.
  • Leave out g. With it, captures holds the other full matches instead of the capture groups.
  • Escape backslashes in JSON: \b is written "\\b". Try a pattern in the regex step tester first.
  • Missing inputs fail the step with "Missing param testString" or "Missing param regex". The exists check on the preview prevents that.
  • One message, one tag. If you add branches for more keywords, the first match wins, as in Branch on lookup results.

Call your own API and handle the response

Use it when the logic lives in your own service: a pricing rule, a lookup in your database, a partner API. Call it with a REST step and branch on what it returns.

REST step with a catch-all branch
{
  "id": "post",
  "action": "kustomer.rest-api.json",
  "params": {
    "method": "post",
    "uri": "https://api.example.com/kustomer/closed",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer {{{variables.6716c1d2c4e5f60012ab36f1}}}"
    },
    "data": { "conversationId": "/#steps.1.id", "customerId": "/#steps.1.customer" },
    "json": true
  },
  "transitions": [
    {
      "target": "tag-synced",
      "condition": { "op": "lt", "values": ["/#steps.post.response.statusCode", 300] },
      "meta": { "name": "Accepted" }
    },
    {
      "target": "note-failed",
      "condition": { "op": "true", "values": [true] },
      "meta": { "name": "Anything else" }
    }
  ]
}

Watch out for:

  • A REST step never fails on a status code. A 404 or 503 is a successful step, and error cases don't apply. Branch on /#steps.post.response.statusCode, and end with an always-true branch so nothing falls through silently.
  • Reference secrets by variable ID, as in the Authorization header above. {{{variables.serviceToken}}}, by name, resolves to nothing. See Reference variables by ID.
  • Set Content-Type yourself, and set "json": true to read fields of the response at /#steps.post.body.<field>.
  • Only set allowNetworkRetry if your endpoint is safe to call twice: a timed-out request may already have reached it.
  • Stay fast. The step waits up to 60 seconds, and the run is stopped after 5 minutes. Hand slow work to your service and report back through an inbound web hook.
  • Use Kustomer actions for Kustomer data, not REST calls to the Kustomer API.

The full guide, including signing and URL rules, is Call your own code.

Reuse logic with a callable workflow

Use it when several workflows need the same steps, when something should run later (see the next pattern), or when one of Kustomer's AI agents should call a workflow as a tool. A callable workflow has no event: its trigger declares the inputs it accepts.

Callable trigger
{
  "id": "1",
  "callable": true,
  "schema": {
    "type": "object",
    "properties": { "conversationId": { "type": "string" }, "reason": { "type": "string" } },
    "required": ["conversationId"]
  },
  "transitions": [
    { "target": "load", "condition": { "op": "exists", "values": ["/#steps.1.conversationId"] } }
  ]
}

Watch out for:

  • Inputs are step 1. The caller's inputs are cast to schema and become step 1's whole output, so read /#steps.1.conversationId, never /#steps.1.inputs.conversationId.
  • Don't set eventName. Kustomer sets it to kustomer.workflow.<workflowId>.call when you save.
  • Re-fetch before acting. Inputs are a snapshot from when the caller ran. Look the record up again, as load does, and check it's still in the state you expect.
  • Outputs are for callers that wait. kustomer.workflow.respond returns its outputs to a caller that waits for a result, such as an AI agent using the workflow as a tool. Declare them in the root response.schema, which the builder edits on its Output Parameters tab.
  • Exports lose the outputs. The builder's Export JSON and Duplicate leave out response, so re-add the output parameters on the copy.
  • Your own code can't call a workflow directly. Post to an inbound web hook and trigger a workflow from it instead.

The full callable example, with load, note and respond, is in Callable and scheduled workflows.

Schedule follow-ups instead of looping

Use it when something should happen later or be checked again: a reminder after 24 hours, a follow-up if the customer hasn't replied. Transitions can't point back to an earlier step, and the API rejects a workflow that tries. Schedule a callable workflow instead.

In the caller: run send-followup in 24 hours
{
  "id": "schedule-followup",
  "action": "kustomer.app.scheduler.schedule-workflow",
  "appVersion": "scheduler-^1.0.1",
  "params": {
    "workflowId": "6716c1d2c4e5f60012ab3702",
    "runAt": { "type": "relative", "value": 86400 },
    "inputs": { "conversationId": "/#steps.1.id", "reason": "No reply after 24 hours" }
  },
  "transitions": []
}

workflowId is the callable workflow's ID. The Schedule a Workflow action comes from the Scheduler app, which must be installed.

Watch out for:

  • relative is whole seconds from now. absolute is an ISO 8601 timestamp. Any other type fails with "runAt type must be one of absolute or relative".
  • The date helper counts backwards. {{date 'hours' count=24}} is 24 hours ago. For a time in the future, use a negative count, {{date 'hours' count=-24}}, or a relative delay. See the Expressions reference.
  • Re-check the state in the callable workflow. By the time it runs, the conversation may be closed, as the load and Status isn't done check handle above.
  • Keep inputs small: IDs, not whole records. Oversized inputs fail with "Payload too large to schedule workflow".
  • The ID is org-specific. When you copy the caller, point workflowId at the target org's copy of the callable workflow.

Make steps safe to repeat

Use it when a workflow creates records or calls other systems. When a Kustomer action gets a 5xx from the API, the step's error cases are skipped and Kustomer runs the workflow again from step 1. Steps that already succeeded run again, and the retry doesn't appear in the workflow's error log. Design every step so that running it twice does no harm.

The Exists path isn't a transition: when no transition matches, the run simply ends.
Check before you create
{
  "steps": [
    {
      "id": "find-conversation",
      "action": "kustomer.conversation.find-by-external-id",
      "params": { "externalId": "order-{{{steps.1.attributes.data.orderNumber}}}" },
      "transitions": [
        {
          "target": "create-conversation",
          "condition": { "op": "dne", "values": ["/#steps.find-conversation.id"] },
          "meta": { "name": "Not created yet" }
        }
      ]
    },
    {
      "id": "create-conversation",
      "action": "kustomer.conversation.create",
      "params": {
        "customer": "/#steps.find-customer.id",
        "externalId": "order-{{{steps.1.attributes.data.orderNumber}}}",
        "name": "Order {{{steps.1.attributes.data.orderNumber}}} shipped"
      },
      "transitions": [
        {
          "target": "add-note",
          "condition": { "op": "exists", "values": ["/#steps.create-conversation.id"] }
        }
      ]
    },
    {
      "id": "add-note",
      "action": "kustomer.note.create",
      "params": { "conversation": "/#steps.create-conversation.id", "body": "Shipped from the store." },
      "transitions": []
    }
  ]
}

This excerpt follows a find-or-create step called find-customer. On a retry, find-conversation finds the conversation the first attempt made, and the run ends instead of creating a second one.

Watch out for:

  • Order matters. Put lookups and repeatable actions, such as adding a tag, first. Put creates and calls to other systems as late as you can, ideally last.
  • Check before you create, by external ID as above, or by a marker: a tag or custom field you check at the trigger and set in the same run.
  • Make REST calls idempotent on your side: send a stable key such as the conversation ID, and ignore repeats.
  • 4xx errors don't retry. They fail the step, and its error cases decide what happens.

Copy a workflow to another org

Use it when you build in a sandbox and promote to production, or share a workflow with another team. The JSON copies cleanly; the IDs inside it don't.

Copy with kusty profiles
kusty --profile sandbox api GET /v1/workflows/$SANDBOX_WORKFLOW_ID --output json \
  | jq '.data.attributes | {name, description, meta, trigger, steps} | del(.steps[]._id)' > wf.json

# List every 24-character ID in the document: each one belongs to the sandbox
grep -oE '[0-9a-f]{24}' wf.json | sort | uniq -c

# After replacing them:
kusty --profile production api POST /v1/workflows --data @wf.json --dry-run
kusty --profile production api POST /v1/workflows --data @wf.json --fields id --output json

Leaving enabled out creates the copy disabled. Then create each variable in the new workflow, replace every variables.<old ID> with the new ID, and send trigger and steps together in a PUT, as in Update it.

Watch out for:

  • Variables are per workflow. A copy has none, and its references still point at the old workflow's variable IDs, which resolve to nothing. Variables are referenced by ID, so recreating them under the same names isn't enough.
  • Hooks and callable workflows have IDs too. A hook trigger's eventName contains the hook's ID, so create the hook in the target org first. Create callable workflows before the workflows that schedule them, so you know the IDs to put in workflowId.
  • Klass names and custom fields must exist in the target org.
  • Names must be unique in the target org. A clash returns 409.
  • Builder exports are partial. Export JSON and Duplicate keep only name, description, meta, trigger and steps. Copies start disabled, lose a callable workflow's output parameters, and drop its eventName, which Kustomer sets again on save.