Write a workflow in JSON
A hands-on tutorial that builds a find-or-create workflow as JSON, one key at a time, then creates, tests, enables and updates it with the Workflows API and kusty. Covers root keys, triggers, steps and params, transitions, error cases, variables by ID, partial updates, and the validation errors you'll meet.
Every workflow is a JSON document. The builder writes that document for you, but you can also write it yourself, keep it in source control, review changes in pull requests, and ship it with one API call. This tutorial builds a real workflow that way, starting from an empty skeleton.
You'll build Record shipped orders. Your store posts to a Kustomer web hook each time an order ships. The workflow looks up the customer by email, creates the customer if there isn't one, and records the order as a custom object on that customer.
Before you start, you need:
- An API key with the
org.admin.workflow.writerole (ororg.permission.workflow.createandorg.permission.workflow.update) and the matching read role. See API keys and roles. - A web hook for the store. Create one and keep its
eventName. - Optionally, kusty, the Kustomer CLI. Every request below is shown with cURL and kusty.
If you haven't read how a run moves through a workflow, start with How workflows run and Data and variables. To build the same kind of workflow by clicking instead of typing, see Build in the builder.
1 {2 "name": "record-shipped-orders",3 "description": "Records shipped orders from the store's web hook, creating the customer if needed.",4 "enabled": false,5 "meta": { "displayName": "Record shipped orders" },6 "steps": []7 }Root keys only. name is stored lowercased and must be unique in your org. meta is flat, and displayName is the title the builder shows.
Start from a skeleton
Create a file called record-shipped-orders.json with the root keys:
{
"name": "record-shipped-orders",
"description": "Records shipped orders from the store's web hook, creating the customer if needed.",
"enabled": false,
"meta": { "displayName": "Record shipped orders" },
"steps": []
}This is already a document the API accepts. It just doesn't do anything yet.
The root accepts a few more keys, such as replaces and logging. Unknown keys are rejected. The Workflow JSON reference lists them all, so this page only covers what you need.
Add the trigger
The trigger names the event that starts a run. For a web hook it's the hook's eventName:
"trigger": {
"id": "1",
"eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive",
"transitions": []
},Replace 5825c452be3af71000ac6462 with your hook's ID. The trigger's id is always "1", so its output is step 1: everything later in the workflow reads the event at /#steps.1…. For a web hook, step 1 is the hook transaction, and the fields your store posted are under attributes.data. When the store posts this:
{ "orderNumber": "4417", "email": "jane@example.com", "name": "Jane Rivera", "total": "129.90" }the email is at /#steps.1.attributes.data.email and the order number at /#steps.1.attributes.data.orderNumber. Record triggers, such as kustomer.conversation.update, flatten the record instead: see What the trigger outputs.
transitions is empty for now, so a run would end right after the trigger. Transitions need a step to point at, which comes next.
Add the first step
Steps go in the steps array. Add one that looks up the customer by the email in the payload, and point the trigger at it:
"trigger": {
"id": "1",
"eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive",
"transitions": [
{
"target": "find-customer",
"condition": {
"op": "and",
"values": [
{ "op": "exists", "values": ["/#steps.1.attributes.data.email"] },
{ "op": "exists", "values": ["/#steps.1.attributes.data.orderNumber"] }
]
},
"meta": { "name": "Has an email and order number" }
}
]
},
"steps": [
{
"id": "find-customer",
"action": "kustomer.customer.find-by-email",
"params": { "email": "/#steps.1.attributes.data.email" },
"meta": { "displayName": "Find the customer" },
"transitions": []
}
]The step has four parts:
idis how the rest of the workflow refers to this step: its output will be stored at/#steps.find-customer. The builder generates random IDs such asSAk4K54Ly; in JSON, pick readable ones. Use letters, numbers,-and_, and no dots, because transition targets can't contain dots.actionis what the step does. Action names and their required inputs are in Triggers and actions. In the builder, this one is Customer: Lookup (By Email).paramsare the action's inputs."/#steps.1.attributes.data.email"is a reference: because the whole string starts with/#, it resolves to the value at that path and keeps its type. Any other string is a Handlebars template and always produces text. Data and variables covers references, templates and functions in depth.meta.displayNameis the step's title in the builder.
The trigger's transition has a target (a step ID) and a condition. This condition is an and group, so the run only reaches find-customer when the payload has both an email and an order number. If no transition matches, the run ends successfully. That's how most runs end, so a strict first condition is the cheapest filter you have. meta.name labels the branch in the builder; name every condition so the canvas reads well.
Use the lookup's output
A step's output is stored under its ID, so the next step can use the customer the lookup found. Add a step that records the order on that customer, and a transition from the lookup to it:
{
"target": "record-order",
"condition": { "op": "exists", "values": ["/#steps.find-customer.id"] },
"meta": { "name": "Customer found" }
} {
"id": "record-order",
"action": "kustomer.kobject.create-with-customer",
"params": {
"klassName": "order",
"customer": "/#steps.find-customer.id",
"title": "Order {{{steps.1.attributes.data.orderNumber}}}",
"data": {
"orderNumberStr": "/#steps.1.attributes.data.orderNumber",
"totalNum": "/#fn:parseFloat,steps.1.attributes.data.total"
}
},
"meta": { "displayName": "Record the order" },
"transitions": []
}Three kinds of values appear in these params:
The transition checks exists because a lookup that finds nothing doesn't fail. The API answers 404, the step succeeds with empty output, and /#steps.find-customer.id is empty. Without the check, record-order would run with no customer.
transitions: [] makes record-order a final step. The order Klass and its orderNumberStr and totalNum fields are examples: use a Klass and fields that exist in your org. See Custom objects.
Branch: find or create
Right now a new customer's order is dropped: the lookup comes back empty, no transition matches, and the run ends. Add a second transition for that case, a step that creates the customer, and a step that records the order on the new customer:
"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" }
}
] {
"id": "create-customer",
"action": "kustomer.customer.create",
"params": {
"name": "/#steps.1.attributes.data.name",
"email": "/#steps.1.attributes.data.email"
},
"meta": { "displayName": "Create the customer" },
"transitions": [
{
"target": "record-order-new-customer",
"condition": { "op": "exists", "values": ["/#steps.create-customer.id"] },
"meta": { "name": "Customer created" }
}
]
},
{
"id": "record-order-new-customer",
"action": "kustomer.kobject.create-with-customer",
"params": {
"klassName": "order",
"customer": "/#steps.create-customer.id",
"title": "Order {{{steps.1.attributes.data.orderNumber}}}",
"data": {
"orderNumberStr": "/#steps.1.attributes.data.orderNumber",
"totalNum": "/#fn:parseFloat,steps.1.attributes.data.total"
}
},
"meta": { "displayName": "Record the order for the new customer" },
"transitions": []
}A few rules shape this:
- Transitions are checked in order, and the first match wins.
existsanddnecan't both pass here, so the order doesn't matter. When conditions can overlap, put the most specific first. dne("does not exist") passes when the value is empty,0,falseor missing.existsis its opposite. The full operator list is in Conditions and variables.- Branches don't merge back. Each branch ends in its own final step, which is why the order step appears twice with a different
customer. Kustomer's own templates are built the same way. - Transitions can't loop back to a step that's already on the path. The API rejects the save (see Validation errors). To repeat work later, schedule a callable workflow.
Handle errors
A Kustomer action that gets a 4xx other than 404, such as a validation error, fails the step. Without an error case, the run stops and the failure is recorded on the workflow. If the store sends an email Kustomer won't accept, create-customer fails that way. To stop quietly instead, add errorCases to the step, before its transitions:
"errorCases": [
{
"condition": {
"op": "or",
"values": [{ "op": "eq", "values": ["/#error.status", 400] }]
},
"errorAction": { "type": "stop-processing" }
}
],- While error cases are checked, the error is at
/#error: the first item of the API'serrorsarray, withstatus,code,title,detailandsource. It's never at/#steps.create-customer.error. - An error case's
condition.opmust beandoror, with up to five conditions inside. An emptyvaluesarray matches every error. Cases are checked in order. stop-processingends the run without recording a failure.continuemoves on to the step's transitions with no output, which is whycreate-customer's transition checksexists. Don't useretry-workfloworretry-action: neither retries.
Leave the error case out if you'd rather see every rejected payload in the workflow's error log.
The finished workflow
{
"name": "record-shipped-orders",
"description": "Records shipped orders from the store's web hook, creating the customer if needed.",
"enabled": false,
"meta": { "displayName": "Record shipped orders" },
"trigger": {
"id": "1",
"eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive",
"transitions": [
{
"target": "find-customer",
"condition": {
"op": "and",
"values": [
{ "op": "exists", "values": ["/#steps.1.attributes.data.email"] },
{ "op": "exists", "values": ["/#steps.1.attributes.data.orderNumber"] }
]
},
"meta": { "name": "Has an email and order number" }
}
]
},
"steps": [
{
"id": "find-customer",
"action": "kustomer.customer.find-by-email",
"params": { "email": "/#steps.1.attributes.data.email" },
"meta": { "displayName": "Find the customer" },
"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" }
}
]
},
{
"id": "record-order",
"action": "kustomer.kobject.create-with-customer",
"params": {
"klassName": "order",
"customer": "/#steps.find-customer.id",
"title": "Order {{{steps.1.attributes.data.orderNumber}}}",
"data": {
"orderNumberStr": "/#steps.1.attributes.data.orderNumber",
"totalNum": "/#fn:parseFloat,steps.1.attributes.data.total"
}
},
"meta": { "displayName": "Record the order" },
"transitions": []
},
{
"id": "create-customer",
"action": "kustomer.customer.create",
"params": {
"name": "/#steps.1.attributes.data.name",
"email": "/#steps.1.attributes.data.email"
},
"meta": { "displayName": "Create the customer" },
"errorCases": [
{
"condition": {
"op": "or",
"values": [{ "op": "eq", "values": ["/#error.status", 400] }]
},
"errorAction": { "type": "stop-processing" }
}
],
"transitions": [
{
"target": "record-order-new-customer",
"condition": { "op": "exists", "values": ["/#steps.create-customer.id"] },
"meta": { "name": "Customer created" }
}
]
},
{
"id": "record-order-new-customer",
"action": "kustomer.kobject.create-with-customer",
"params": {
"klassName": "order",
"customer": "/#steps.create-customer.id",
"title": "Order {{{steps.1.attributes.data.orderNumber}}}",
"data": {
"orderNumberStr": "/#steps.1.attributes.data.orderNumber",
"totalNum": "/#fn:parseFloat,steps.1.attributes.data.total"
}
},
"meta": { "displayName": "Record the order for the new customer" },
"transitions": []
}
]
}Kustomer adds an appVersion, such as kustomer-^1.2.1, to steps saved in the builder. It's optional when you write JSON.
Create it
post/v1/workflows
Preview the request first, then send it:
kusty api POST /v1/workflows --data @record-shipped-orders.json --dry-run
kusty api POST /v1/workflows --data @record-shipped-orders.json --fields id,attributes.name,attributes.enabled --output json{
"data": {
"type": "workflow",
"id": "6716c1d2c4e5f60012ab3700",
"attributes": {
"name": "record-shipped-orders",
"enabled": false,
"trigger": { "id": "1", "eventName": "kustomer.hooks.web.5825c452be3af71000ac6462.receive", "transitions": [ … ] },
"steps": [ … ],
"lastError": {}
}
}
}Keep data.id. You'll use it to update the workflow, add variables and read its failures:
export WORKFLOW_ID=6716c1d2c4e5f60012ab3700kusty checks the body against the API's JSON Schema before it sends anything, so a misspelled key or a nested meta fails locally with SCHEMA_VALIDATION_FAILED (exit code 2). Checks that need your org, such as unique names and the loop check, happen on the server. See Validation errors.
Reference variables by ID
This workflow doesn't need a secret, but many do: for example, a REST step that calls your warehouse's API with a token. Store the token in a workflow variable, never in params. Variables belong to one workflow, so create them after the workflow exists:
kusty api POST /v1/workflows/$WORKFLOW_ID/variables \
--data '{ "name": "warehouseToken", "value": "wh_live_…", "type": "string" }' \
--fields id,attributes.name --output json{ "data": { "id": "6716c1d2c4e5f60012ab36f1", "type": "variable", "attributes": { "name": "warehouseToken", "maskedValue": "…", "type": "string" } } }Reference the variable by its ID, data.id, not its name. As a whole param use /#variables.<id>, and inside a template use {{{variables.<id>}}}:
{
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{{variables.6716c1d2c4e5f60012ab36f1}}}"
}
}{{{variables.warehouseToken}}} resolves to nothing, and so does an ID from another workflow. Variable values are strings of up to 1,024 characters, and the API only ever returns the last six characters. Then add the step with a PUT, as described below.
Turn it on and test
A disabled workflow doesn't run, so testing a hook workflow means enabling it. Test in a sandbox org if you can, because this workflow creates real customers and orders.
Enable it and turn on logging
Send only the keys you're changing:
kusty api PUT /v1/workflows/$WORKFLOW_ID --data '{ "enabled": true, "logging": true }'logging: truerecords every run for the next hour, the same as Debug Mode in the builder's View Logs.Wait about a minute
Workflows are cached, so a created, enabled or changed workflow takes about a minute to reach new runs.
Post one payload per path
Send an order for an existing customer, one for a new email, and one without an
orderNumber:Shellcurl -X POST "$HOOK_URL" \ -H "Authorization: Bearer $KUSTOMER_HOOK_KEY" -H "Content-Type: application/json" \ -d '{ "orderNumber": "4417", "email": "jane@example.com", "name": "Jane Rivera", "total": "129.90" }'Read the runs
Open the workflow in the builder and select View Logs to see each run's path and every step's input and output. The first should end at
record-order, the second atrecord-order-new-customer, and the third right after the trigger. Failed runs are also inGET /v1/workflows/{id}/processes. See Debugging.
Update it
put/v1/workflows/{id}
PUT /v1/workflows/{id} updates only the root keys you send. Each key you send replaces the stored value whole, and keys you leave out stay as they are. That's how the builder works too: its on/off switch sends only enabled, and Save sends trigger and steps.
Always send trigger and steps together. The trigger's transitions point at step IDs, so replacing one without the other can leave a transition aimed at a step that no longer exists, and the run fails with next step not found.
To change the logic, fetch the current document, keep only the keys you're changing, edit, preview and send:
kusty api GET /v1/workflows/$WORKFLOW_ID --output json \
| jq '.data.attributes | {trigger, steps} | del(.steps[]._id)' > logic.json
# edit logic.json
kusty api PUT /v1/workflows/$WORKFLOW_ID --data @logic.json --dry-run
kusty api PUT /v1/workflows/$WORKFLOW_ID --data @logic.json- Don't send back what the API adds:
lastError,editable,containsVariables, the timestamps, and the_idon each stored step. Selecting keys withjq, as above, leaves them out. - Keep step IDs stable. Every
/#steps.<id>reference and everytargetdepends on them. If you rename a step, update all of them. - Changing
namechanges how the workflow is listed; look workflows up byid, which never changes. - Kustomer keeps the 30 most recent versions of a workflow. The builder's version picker opens older ones read-only.
- Allow about a minute before testing the change.
Validation errors you'll hit
The API validates every create and update. These are the errors you're most likely to see while writing JSON by hand:
Some mistakes pass validation and only show up at run time: a target that doesn't match any step ID (next step not found), a reference to a misspelled path (it silently resolves to nothing), or a variable referenced by name. Test every path, as described above.