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.
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.
{
"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
existsanddneof itsid. 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.
{
"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.afteris set only when this update changed the status./#steps.1.statusisdonefor every later update too. - Custom fields change as one
customentry, 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
dneon/#steps.1.importedAtandneqof/#steps.1.sourceand"bulk". To skip messages written by bots, look up the author and checkneqof theiruserTypeand"machine". - Mark the text you post. A workflow that writes messages or notes should include a marker such as
[auto]and checknot-containson the preview, so it doesn't answer itself. neqpasses when the field is missing. Pair it withexistswhen 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": {
"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
existscheck 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 andeqis false. - Tags, users and teams are IDs, never names.
- Use
append-tagsrather thanconversation.updatewithtags, 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": {
"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,capturesholds the other full matches instead of the capture groups. - Escape backslashes in JSON:
\bis 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
existscheck 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.
{
"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
404or503is 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
Authorizationheader above.{{{variables.serviceToken}}}, by name, resolves to nothing. See Reference variables by ID. - Set
Content-Typeyourself, and set"json": trueto read fields of the response at/#steps.post.body.<field>. - Only set
allowNetworkRetryif 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.
{
"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
schemaand become step1's whole output, so read/#steps.1.conversationId, never/#steps.1.inputs.conversationId. - Don't set
eventName. Kustomer sets it tokustomer.workflow.<workflowId>.callwhen you save. - Re-fetch before acting. Inputs are a snapshot from when the caller ran. Look the record up again, as
loaddoes, and check it's still in the state you expect. - Outputs are for callers that wait.
kustomer.workflow.respondreturns itsoutputsto a caller that waits for a result, such as an AI agent using the workflow as a tool. Declare them in the rootresponse.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.
{
"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:
relativeis whole seconds from now.absoluteis an ISO 8601 timestamp. Any othertypefails with "runAt type must be one of absolute or relative".- The
datehelper 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 arelativedelay. See the Expressions reference. - Re-check the state in the callable workflow. By the time it runs, the conversation may be closed, as the
loadandStatus isn't donecheck 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
workflowIdat 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.
{
"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.
4xxerrors 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.
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 jsonLeaving 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
eventNamecontains 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 inworkflowId. - 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,triggerandsteps. Copies start disabled, lose a callable workflow's output parameters, and drop itseventName, which Kustomer sets again on save.