Skip to main content

Journeys overview

Journeys are the feature for building impact services in Turn. Journeys are heavily inspired by the original ideas in Hypercard and Hypertalk.

caution

Journeys were previously called stacks. The words "stacks" and "journeys" may be used interchangably in our documentation and you may still see stacks referred to in things like function names.

In Turn, journeys are containers that hold one or more cards and the relationships between cards. Cards perform the various steps that make up your impact service using functions and expressions.

The available functions and expressions are described below with examples and common errors.

Journeys coding language

Impact services built using journeys are described using a custom coding language. We sometimes refer to this as the Journeys DSL, which stands for Journeys Domain Specific Language.

Let's look at the code language structure and syntax.

Journey code block

Think of a journey as a container that holds multiple cards.

Example

The following example creates an empty journey for illustrative purposes.

stack JourneyName do
...
end

Key things to note

  • Your journey must have a name.
  • The do command initiates the journey.
  • The end command concludes the journey.
  • All logic that the journey should execute will be added between the do and the end commands.

You can omit the stack do ... end which will have the system implicitly assume a single journey describes all the cards defined.

Card code block

Think of a card as performing one or more steps on your impact service. Cards make use of various functions and expressions to describe these steps.

Example

The following example creates an empty card for illustrative purposes.

card CardName do
...
end

Key things to note

  • The code structure to create a card is similar to the code structure to create a journey.
  • Your card code block will always be defined within a journey code block.
  • Your card must have a name.
  • The do command initiates the card.
  • The end command concludes the card.
  • All logic of the card will be added between the do and the end commands.
  • Logic will comprise functions, expressions, etc. See the examples below.

Using comments

Any line of text in your journey that is prefixed with a # sign will become a comment. This means it will not be an executable line of code but will be ignored when the journey runs.

card MyCard do
# This is a comment
end

Documenting your Journeys

Code comments are great to document individual lines within a Card but sometimes these can be too limiting. When one wants to describe larger overarching concerns like providing more detail behind specific design decisions, linking to external documentation or research, or to describe key implementation details, Markdown documentation blocks are what you'd want to use.

Markdown is a simple text based format used to provide extra markup to plain text. There's a great Markdown tutorial available at https://www.markdowntutorial.com that covers the details and there's also the Markdown Cheat Sheet if you're looking for a quick reference guide.

markdown-block

The idea behind combining cards, to describe the logic, and Markdown, to describe the thought process and background information, is that these journeys will become self documenting over time. In fact, your whole impact service is itself described as one large Markdown text file.

Between your cards you can add as many Markdown blocks as needed to describe the logic of your impact service.

Your first journey

Now that we know a journey contains one or more cards, and each card contains logic that describe your impact service, let's put it together. The example shows you how journey blocks and card blocks fit together to create a very simple journey.

Example

The following example creates a simple journey that sends a single text message to a user, saying "Hello World!". Note how the card block sits within the journey block.

stack MyJourney do
card MyCard do
text("Hello world!")
end
end

Relating cards

Cards describe the steps of your impact service. Each card describes a specific point in a series of interactions.

The first card is always what the journey starts with. There after, the order in which cards appear in your journey code is not important. What is important is to describe the relationships between cards, as that determines how users will navigate between them.

There are two main ways to describe what happens after each card, i.e. the then or the when condition.

The then condition

The then relationship is used to connect one card with another card.

Syntax

card FirstCard, then: NextCard do
...
end

Example

This example shows how to use the then: keyword to indicate when a card should immediately be followed by another card. In this case two text messages will be sent immediately after each other, first "this is the first card" and then "this is the second card".

card One, then: Two do
text("this is the first card")
end

card Two do
text("this is the second card")
end

Key things to note

  • Without then: only the first text message will be sent.

The when condition

The when clause is used to add conditions that control the execution order of a card.

Syntax

card CardName when some_condition do
...
end
info

Note that with the then syntax we used a comma and a colon (CardName, then:). However when should occur immediately after the cardName without a comma or colon.

Boolean Operators

This is a list of the comparison functions that can be used within Journeys:

  • < - less than
  • > - greater than
  • >= - greater than or equal to
  • <= - less than or equal to
  • = - equal to
  • == - equal to
  • != - not equal to
  • <> - not equal to

And they can be combined with the standard and and or operators, as well as the not keyword.

You can use parenthesis to determine the order of evaluation:

  • (true or false) and false will evaluate to false
  • true or (false and false) will evaluate to true

Finally, when it comes to truthy and falsey values:

  • variables that are nil or null will evaluate as false
  • simply checking a variable that is not the boolean value of true, will evaluate to false. So any use of when some_variable do will evaluate to false. Otherwise, you must include a comparison. To expand, some language might declare that a non-empty string or non-empty list evaluates to true, but this is not the case in Journeys.
info

If you're ever unsure about what an expression is evaluating to, you can use the log function to view the output in the simulator.

Example

This following example elaborates a bit on the previous example by introducing conditions between cards.

You'll notice that card One proceeds to card Two with the then: keyword. One important difference here is that we're using the ask() function which waits for user input before proceeding.

Card Two is defined multiple times, two of which have conditions that guard the card. Depending on the user's response the system will automatically select the card Three for which the condition resolves to true. The last card Two has no condition which means it will always resolve to true and functions as a default fallback.

card One, then: Two do
text("this is the second card")
age = ask("What is your age?")
end

card Two when age > 18 do
text("Hello boomer")
end

card Two when age > 16 and age <= 18 do
text("Hey there!")
end

card Two do
text("This service is too cool for you")
end

Triggers

Triggers are defined at the top of your journey and specify when your journey should execute. For example, you can use the following Trigger to have your journey start when a users sends the message "hi" to your service:

trigger(on: "MESSAGE RECEIVED") when has_phrase(event.message.text.body, "hi")

card FirstCard do
text("Welcome!")
end

Once triggered, the journey will start executing from its first card.

A Trigger is composed of an event like MESSAGE RECEIVEDand an optional expression (specified after the when keyword) that can be used to match properties on the inbound message and/or the contact.

There are two types of Triggers:

  • Inbound Message Triggers: these Triggers execute when a user messages your service.
  • Time Triggers: these Triggers execute at a specified date/time or some time before/after a specific event (as in "2 hours after sign up"). They can also be configured to run periodically on a schedule (like "every monday at 15:00").

We break down the code aspects of triggers in the following sections, but you can also learn by doing - create a trigger in the no-code canvas and then switch to code view in order to view the generated code. Please note that the code is read-only and you cannot edit the code directly:

demo of using no-code to generate trigger code

Inbound Message Triggers

These Triggers execute when a contact messages your service. The following events are supported:

  • MESSAGE RECEIVED: triggers when a contact send a message to your service.
  • FIRST TIME: triggers when a contact messages your service for the very first time.
  • CATCH ALL: triggers if no Trigger (or Automation) handled the inbound message.

You can further refine the Trigger by providing an expression that matches the properties of the inbound message and/or the properties of the contact who sent the message.

The following example matches messages containing the word "hi" but only for contacts that have opted in:

trigger(on: "MESSAGE RECEIVED")
when has_phrase(event.message.text.body, "hi") and contact.opted_in == true


card FirstCard do
text("Welcome opted-in user!")
end

In the expression, you can refer the inbound message with event.message (and its body with event.message.text.body) and the contact who sent the message with contact. To refer contact profile fields, you can use the contact.field_name_here syntax.

The following Expressions are supported in Inbound Message Triggers:

All of the boolean operators and syntax discussed for when statements are supported within a message trigger.

Time Triggers

Time Triggers execute at a specified date/time or some time before/after a specific event (as in "2 hours after sign up"). They can also be configured to run periodically on a schedule (like "every monday at 15:00").

The following Time Triggers are supported:

  • Specific-time: triggers at a specified datetime, for example: trigger(at: "2024-09-13T15:45:00Z").
  • Recurring: triggers on a cron-like recurring schedule, for example: trigger(every: "30 15 * * MON").
  • Relative to a profile field: triggers some time before/after the datetime value contained in a contact profile field, for example: trigger(interval: "+3d", relative_to: "contact.due_date").

As with Inbound Message Triggers, it is possible to further refine the Trigger by providing an expression after the when keyword. The expression acts as a filter for your contacts: the trigger will be executed for all the contacts for which the expression returns true.

As an example, the following Trigger will execute every Tuesday at 10:30 for all contacts that have the is_pregnant profile field set to true:

trigger(every: "30 10 * * TUE") when contact.is_pregnant == true

The following Expressions are supported in Time Triggers:

All of the boolean operators and syntax discussed for when statements are supported within a time trigger.

An important note on message templates: when using Time Triggers it's often hard to tell if the trigger will execute inside WhatsApp's 24 hours service window for a contact. Since WhatsApp only allows sending message templates outside the 24 hours service window, we strongly suggest using message templates (in place of normal messages) in journeys using Time Triggers.

Specific-time Triggers

Specific-time triggers will execute at the specified datetime for all contacts for which the provided expression evaluates to true.

The datetime is provided with the at: parameter and must be a valid ISO8601 datetime string.

For example, the following journey will trigger at 15:45 on the 13th of September 2024 for all contacts having the opted_in field set to true or the registration_status field set to "completed".

trigger(at: "2024-09-13T15:45:00Z") when
contact.opted_in == true or contact.registration_status == "completed"

Recurring Triggers

Recurring triggers will execute on a recurring schedule specified using a cron schedule expression.

We recommend using the Crontab Guru website to build and validate your cron expression. Please note that the cron expressions will be evaluated using the UTC timezone.

It is also possible to specify and end date for the recurring trigger by specified the optional until parameter. The until parameter needs to be a valid ISO8601 datetime value.

For example, the following trigger will execute every Monday at 15:30 (until September 13 2025) for all contacts having the language field set to any value other than "en".

trigger(every: "30 15 * * MON", until: "2025-09-13T15:45:00Z") when
not has_phrase(contact.language, "en")

Triggers relative to a profile field

These triggers execute at a specified time interval relative to the datetime value contained in a profile field.

For example, they can be used to trigger a journey 2 hours after a contact enrolls in a program:

trigger(interval: "+2h", relative_to: "contact.enrolled_at") when
has_phrase(contact.project_enrolled, "project_a")

The relative time interval is provided as the interval argument and must be a string composed by the following elements:

  • + or - sign, where + means after and - means before.
  • an integer
  • a time unit from the following supported ones:
    • m for minutes.
    • h for hours.
    • d for days.
    • w for weeks.
    • M for months.

The contact field must be of type date and must be specified as the relative_to argument using the "contact.field_name" syntax.

The following example messages the user 3 days before their birthday:

trigger(interval: "-3d", relative_to: "contact.birthday")

card UpcomingBirthday do
send_message_template("birthday_3d_before", "en", [])
end

It is also possible to override the time at which the trigger should execute by using the target_time option (specified as a valid UTC ISO8601 time string). This is useful when using interval units such as days/weeks/months to avoid messaging people at inconvenient times.

For example, the following Trigger will execute 2 weeks before the contact's due_date datetime field value at exactly 15:30:

trigger(interval: "-2w", relative_to: "contact.due_date", target_time: "15:30:00") when
contact.is_expecting == true

Variables

As in many programming languages, variables can be used to store values. All variables declared in a journey are available to all the cards in the journey, and not just in the card where the variable was created.

info

The values assigned to variables are only available for the duration of the journey and will get lost once the user finishes interacting with the journey. if you need to save data for later retrieval (for example to retrieve it from some other journeys in the future), please read about contact profiles.

card CardOne, then CardTwo do
first = 2
end

card CardTwo, then CardThree do
second = 3
result = first + second
end

card CardThree do
# This sends the message "The result is: 5"
text("The result is: @result")
end

Content Tables

A common use-case when writing Journeys is to store some content in a way that makes it easy to interact with from a card.

An example is a 10 question survey. The logic described in the cards doesn't need to change if the content was updated to have fewer or more questions. We simply just want to go through all available questions one by one until all have been answered.

Content Tables are great for these kinds of use cases. They have a unique name and function much like a spreadsheet, allowing one to specify columns with names and rows for each.

content-table

From within a card, the content table's rows can be read one by one using Expressions. Tables have a list attribute called rows which gives access to all the rows in the table. Each individual row's values can be addressed using the column's name in lowercase.

card TextCard do
row = table_0.rows[0]
text("""
The value of the first rows is: @row.column_1, @row.column_2, and @row.column_3
""")
end

content-table-example

Parameters

Often, as one writes journeys, there will be common bits of text and values that are re-used between cards. Think of things like the welcome text on a button that takes one back to the main menu for example.

It's often desirable to present these common elements in an easily editable interface outside of code objects in a Journey.

Parameters are great for this as they allow one to assign a name to a commonly reused element and provide a default value for it. When writing cards, one can then reference to these values by name using Expressions

Parameters are similar to Content Tables except that they are optimised for reading individual values from rather than combined rows.

The values in parameters are stored in an attribute called items from where the individual entries can be read by name in lowercase.

card TextCard do
text("""
@parameters_0.items.welcome_text

@parameters_0.items.intro
""")
end

parameters

Use multi-line text

Multi-line text is wrapped by triple quotes (""") and allows you to easily represent text spanning over multiple lines.

card QuestionCard do
ask("""
Welcome!
Is "Jane" your name?
""")
end

It also allows you to use quotes (") inside without having to escape them:

card Card do
data = parse_json("""
{
"my": "json",
"structure": [1, 2, 3]
}
""")
end
info

Single quotes are no longer supported for string values. This was previously supported but going forward this will generate an error when saving a journey.

If you are using single quotes like the following example:

map(0..10, &concatenate(&1, ', ')

Use double quotes instead:

map(0..10, &concatenate(&1, ", ")

If you are needing to use quotes in a string you can escape them with a backslash: \".

map(0..10, &concatenate(&1, "\","))

The code block will alert you when you are using single quotes the next time you save your code:

single quote validation screenshot

Common errors

  • All the lines including the triple quotes, must be indented at the same level, per the example below.
  • Don't put any text in the same line as the triple quotes. For example, don't do ask("""Welcome, only ask(""" and the text on the next line.

Formatting text

WhatsApp allows you to format your text into bold, italics, strike-through and monospace.

FormatSyntaxExample
Italicsplace an underscore () on both sides of the text_your text_
Boldplace a star (*) on both sides of the text*your text*
Strikethroughplace a tilde (~) on both sides of the text~your text~
Monospaceplace three backticks (```) on both sides of the text```your text```

Journey Performance

When creating Journeys, we encourage keeping performance in mind; creating rapid and responsive services for your end users on WhatsApp. One of the things that we do in order to encourage a responsive service is to set a hard timeout of 30 seconds for Journeys to complete an action in response to a users message. For example if you rely on a number of outbound webhook calls to augment your service, ensure that they respond within a reasonable amount of time to allow for conversations to flow naturally.