# 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.

Source: https://developer.kustomer.com/docs/workflows/patterns

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](#reuse-logic-with-a-callable-workflow).
- **Create workflows disabled**, add their variables, test every path, then enable them. See [Write a workflow in JSON](https://developer.kustomer.com/docs/workflows/write-workflow-json).

| Pattern                                                                                 | Use it when                                            |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [Find or create](#find-or-create)                                                       | An event identifies a record that may not exist yet    |
| [Guard update triggers with changes](#guard-update-triggers-with-changes)               | You use any `*.update` trigger                         |
| [Branch on lookup results](#branch-on-lookup-results)                                   | The decision depends on data the event doesn't carry   |
| [Tag on a keyword with a regex](#tag-on-a-keyword-with-a-regex)                         | You need to find a pattern in text                     |
| [Call your own API and handle the response](#call-your-own-api-and-handle-the-response) | The logic lives in your service                        |
| [Reuse logic with a callable workflow](#reuse-logic-with-a-callable-workflow)           | Several workflows, or an AI agent, need the same steps |
| [Schedule follow-ups instead of looping](#schedule-follow-ups-instead-of-looping)       | Something should happen later, or be checked again     |
| [Make steps safe to repeat](#make-steps-safe-to-repeat)                                 | A workflow creates records or calls other systems      |
| [Copy a workflow to another org](#copy-a-workflow-to-another-org)                       | You 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.

_Find or create. A web hook trigger goes to a Customer: Lookup (By Email) step. If the customer was found, the first transition goes to Record the order. If there is no customer yet, the second transition goes to Customer: Create, which then goes to its own Record the order step._

```json title="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](#make-steps-safe-to-repeat).

The [JSON tutorial](https://developer.kustomer.com/docs/workflows/write-workflow-json) 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.

_Guarding an update trigger. A Conversation updated trigger has one transition, to Add tags, taken only when changes.attributes.status.after equals done and the conversation doesn't already have the marker tag. Every other update matches no transition, so the run ends right after the trigger._

```json title="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](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#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.

_Branching on a lookup. A Conversation created trigger goes to Customer: Lookup (By ID) when the conversation has a customer. From the lookup, the first transition goes to Add the VIP tag when the customer's vipBool field is true, and the second goes to Add the gold tag when tierStr equals gold. A VIP gold customer only gets the VIP tag, because the first match wins._

```json title="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).

_Tagging on a keyword. A Message created trigger goes to a Generic Regex Match step only for the first inbound email that has a preview. The regex step goes to Conversation: Add Tags when it found a match. Without a match, the run ends._

```json title="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](https://developer.kustomer.com/docs/workflows/triggers-and-actions#regex-match) 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](#branch-on-lookup-results).

> **Plain keywords without a regex:** `contains` is case-sensitive, so lowercase the text with a template first: `{ "op": "contains", "values": ["{{{toLower steps.1.preview}}}", "refund"] }`. Conditions accept templates as well as references.

## 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.

_Calling your own API. A Conversation updated trigger goes to a Rest API: JSON step when the status just became done. The REST step's first transition goes to Add the synced tag when the status code is below 300. Its second, always-true transition goes to Note: Create, which records the failure._

```json title="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](https://developer.kustomer.com/docs/workflows/write-workflow-json#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](https://developer.kustomer.com/docs/events/inbound-webhooks).
- **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](https://developer.kustomer.com/docs/workflows/code-steps).

## Reuse logic with a callable workflow

**Use it when** several workflows need the same steps, when something should run later (see the [next pattern](#schedule-follow-ups-instead-of-looping)), 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.

_A callable workflow reused by two callers. Workflow A and workflow B each have a Schedule a Workflow step that passes inputs to the callable workflow's trigger. The callable trigger goes to Conversation: Lookup (By ID) when a conversationId was passed, then to Note: Create, then to Workflow: Send Response, which returns outputs to callers that wait for them._

```json title="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](https://developer.kustomer.com/docs/events/inbound-webhooks) and trigger a workflow from it instead.

The full callable example, with `load`, `note` and `respond`, is in [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#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.

_A scheduled follow-up. A Conversation created trigger goes to a Schedule a Workflow step that runs the follow-up workflow in 24 hours. Twenty-four hours later the callable trigger receives the conversationId and goes to Conversation: Lookup (By ID). If the conversation's status isn't done, it goes to Note: Create to add a follow-up note; otherwise the run ends._

```json title="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](https://developer.kustomer.com/docs/workflows/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](#copy-a-workflow-to-another-org), 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.

_An idempotent create. A web hook trigger goes to Conversation: Lookup (By External ID), searching for the order's external ID. If no conversation has that ID yet, it goes to Conversation: Create, which sets the external ID, then to Note: Create. If the conversation already exists, for example because this is a retry, no transition matches and the run ends without creating a duplicate._

```json title="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](#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](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#handle-errors) 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.

_Copying a workflow between orgs, as five steps you run. Fetch the workflow from the sandbox and keep name, description, meta, trigger and steps. Replace org-specific IDs such as tags, teams, users, the hook ID in eventName and scheduled workflowIds. Create it disabled in production with POST /v1/workflows. Recreate its variables and update every variable ID with PUT, sending trigger and steps together. Test it, then enable it._

```bash title="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](https://developer.kustomer.com/docs/workflows/write-workflow-json#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.

## Related
