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.write role (or org.permission.workflow.create and org.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.

InteractiveGrow the workflow JSON
record-shipped-orders.json7 lines
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 }
Stage 1 of 6
Skeleton

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.

No trigger yet, so nothing can start a run.
Each stage is a complete document you could send to POST /v1/workflows. Highlighted lines are what the stage added; changed lines count as added. The diagram draws the same steps and transitions. The error case isn't a transition, so it appears as a chip on its step.

Start from a skeleton

Create a file called record-shipped-orders.json with the root keys:

record-shipped-orders.json
{
  "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.

KeyRules
nameRequired. Letters, numbers, -, _ and ., up to 128 characters, no spaces. Kustomer stores it lowercased and trimmed, and it must be unique in your org, so write it in lowercase yourself. The builder makes the name from the title the same way: Record shipped orders becomes record-shipped-orders
descriptionUp to 4,096 characters
enabledNew workflows are created disabled. Keep it false until you've tested
metaMust be flat: up to 100 keys matching ^[a-zA-Z][a-zA-Z0-9-_]{1,64}$, each holding a string (up to 1,024 characters), number, boolean or null. Nested objects are rejected. displayName is the title the builder shows, so it can have spaces and capitals
trigger, stepsAdded in the next sections

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:

Add after meta
  "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:

What the store posts
{ "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:

record-shipped-orders.json (trigger and steps)
  "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:

  • id is 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 as SAk4K54Ly; in JSON, pick readable ones. Use letters, numbers, - and _, and no dots, because transition targets can't contain dots.
  • action is 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).
  • params are 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.displayName is 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:

Add to find-customer's transitions
        {
          "target": "record-order",
          "condition": { "op": "exists", "values": ["/#steps.find-customer.id"] },
          "meta": { "name": "Customer found" }
        }
Add to steps
    {
      "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:

ValueKindResult
"/#steps.find-customer.id"ReferenceThe customer's ID, read from the lookup's output
"Order {{{steps.1.attributes.data.orderNumber}}}"TemplateThe text Order 4417. Triple braces insert the value without HTML escaping
"/#fn:parseFloat,steps.1.attributes.data.total"FunctionThe number 129.9. Function arguments are bare paths, without /#

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:

find-customer's transitions
      "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" }
        }
      ]
Add to steps
    {
      "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": []
    }
Transitions are checked left to right and the first match wins. Each branch ends in its own step.

A few rules shape this:

  • Transitions are checked in order, and the first match wins. exists and dne can'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, false or missing. exists is 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:

Add to create-customer
      "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's errors array, with status, code, title, detail and source. It's never at /#steps.create-customer.error.
  • An error case's condition.op must be and or or, with up to five conditions inside. An empty values array matches every error. Cases are checked in order.
  • stop-processing ends the run without recording a failure. continue moves on to the step's transitions with no output, which is why create-customer's transition checks exists. Don't use retry-workflow or retry-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

record-shipped-orders.json
{
  "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
201 Created (abridged)
{
  "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:

Shell
export WORKFLOW_ID=6716c1d2c4e5f60012ab3700

kusty 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
201 Created (abridged)
{ "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>}}}:

A REST step's headers
{
  "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.

  1. 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: true records every run for the next hour, the same as Debug Mode in the builder's View Logs.

  2. Wait about a minute

    Workflows are cached, so a created, enabled or changed workflow takes about a minute to reach new runs.

  3. Post one payload per path

    Send an order for an existing customer, one for a new email, and one without an orderNumber:

    Shell
    curl -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" }'
  4. 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 at record-order-new-customer, and the third right after the trigger. Failed runs are also in GET /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.

To changeSend
On or off{ "enabled": false }
The titleThe whole meta object, because it's replaced as a unit: { "meta": { "displayName": "Record shipped store orders" } }
Any step or transitiontrigger and steps, together

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 _id on each stored step. Selecting keys with jq, as above, leaves them out.
  • Keep step IDs stable. Every /#steps.<id> reference and every target depends on them. If you rename a step, update all of them.
  • Changing name changes how the workflow is listed; look workflows up by id, 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:

StatusError mentionsCause and fix
400Step with ID <id> transitions to a previous step with ID <workflowId>:<target>A transition points back to a step already on the path. Workflows can't loop. To repeat work later, schedule a callable workflow
400uniqueItemsTwo identical transitions on one step. Remove the duplicate
409the nameAnother workflow in the org already has this name. Names are compared after lowercasing, so Record-Shipped-Orders and record-shipped-orders clash. Pick a new name, or update the existing workflow instead
400additionalPropertiesAn unknown key, a read-only attribute copied from a GET, or a nested object in meta
400target patternA step ID used as a target contains a dot
400maxItemsMore than 10 transitions or 10 error cases on one step, or more than 5 conditions in an error case
400Invalid app settings configuration passedA {{{ setting … }}} template refers to a setting that doesn't exist
409The workflow was installed by an app and can't be edited. You can still turn it on or off and toggle logging. To change it, copy it and use replaces

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.

Next