Journeys overview
Journeys are the feature for building impact services in Turn. Journeys are heavily inspired by the original ideas in Hypercard and Hypertalk.
Journeys were previously called stacks. The words "stacks" and "journeys" may be used interchangeably 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.
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
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 tofalse
true or (false and false)
will evaluate totrue
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 tofalse
. So any use ofwhen 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.
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 RECEIVED
and 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:
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:
has_all_members
has_any_member
has_any_beginning
has_any_end
has_any_exact_phrase
has_any_phrase
has_all_words
has_any_word
has_beginning
has_date
has_date_eq
has_date_gt
has_date_lt
has_email
has_end
has_group
has_member
has_number
has_number_eq
has_number_gt
has_number_gte
has_number_lt
has_number_lte
has_only_phrase
has_only_text
has_pattern
has_phone
has_phrase
has_text
has_time
isbool
is_nil_or_empty
isnumber
isstring
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.
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.
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
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
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
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:
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
, onlyask("""
and the text on the next line.
Formatting text
WhatsApp allows you to format your text into bold, italics, strike-through and monospace.
Format | Syntax | Example |
---|---|---|
Italics | place an underscore () on both sides of the text | _your text_ |
Bold | place a star (*) on both sides of the text | *your text* |
Strikethrough | place a tilde (~) on both sides of the text | ~your text~ |
Monospace | place 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.