Skip to main content

Core Concepts

The on_event Function

The on_event function in your main .lua file is the heart of your application. It's the single entry point that receives and routes all events from the Turn platform. Common events include:

  • "install": Your app is being installed.
  • "uninstall": Your app is being uninstalled.
  • "upgrade": Your app is being upgraded from an older version. See App Upgrades for details.
  • "downgrade": Your app is being downgraded to an older version. See App Upgrades for details.
  • "config_changed": Your app's configuration has been updated. This is a good time to re-evaluate settings or subscriptions.
  • "contact_fields_changed": A contact was updated, and one or more of the changed fields are fields your app has subscribed to. The data table will contain uuid (the contact's UUID), contact (the full contact), and changes (a list of tables, each with field, old, and new keys).
  • "delivery_errors": A message failed delivery with an error code your app has subscribed to via turn.app.set_delivery_error_subscriptions(). The data table contains an errors list where each item includes code, status, message_id, contact, timestamp, and upstream_error (the raw error from Meta).
  • "http_request": Your app's public webhook URL received a request. The data table contains method, request_path, path_info, query_string, req_headers, body_params, query_params, and params (merged query + body params).
  • "journey_event": A Journey has called your app.

Your function will receive four arguments:

  • app: A table containing your app's instance configuration, including its unique UUID.
  • number: A table with information about the number where the app is installed.
  • event: A string identifying the event type.
  • data: A table containing data specific to that event.

A core feature of apps is subscribing to changes on specific contact fields. You can set these subscriptions using the new turn.app API, typically in response to the install and config_changed events.

local App = {}
local turn = require("turn")

function App.on_event(app, number, event, data)
if event == "install" or event == "config_changed" then
-- On install or config change, (re)set the contact fields we want to watch.
-- This uses the new `turn.app` API.
turn.app.set_contact_subscriptions({"name", "surname", "age"})
return true
elseif event == "contact_fields_changed" then
-- React to specific, subscribed fields being updated on a contact.
local contact_uuid = data.uuid
local changes = data.changes -- A list of tables with field, old, new keys
local _updated_contact = data.contact -- The full contact with all fields, including the changed ones

for _, change in ipairs(changes) do
turn.logger.info(
"Contact " .. contact_uuid .. " field '" .. change.field .. "' changed from '" ..
tostring(change.old) .. "' to '" .. tostring(change.new) .. "'"
)
end
return true
elseif event == "delivery_errors" then
-- React to message delivery failures for subscribed error codes.
for _, error in ipairs(data.errors) do
turn.logger.info(
"Message " .. error.message_id .. " failed with code " .. error.code ..
" for contact " .. error.contact.uuid
)
-- error.upstream_error contains the raw error from Meta's API.
-- See: https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes
end
return true
elseif event == "http_request" then
-- Handle an incoming HTTP request to your app's unique endpoint
return true, { status = 200, body = "Hello from my app!" }
else
-- It's good practice to handle unhandled events like `uninstall` or the general `contact_changed`.
turn.logger.warning("Received unhandled event: " .. event)
return false
end
end

return App

Integrating with Journeys

One of the most powerful features of Lua Apps is their ability to integrate directly with Journeys using the app() block. This allows you to add custom logic, calculations, or API calls right in the middle of a conversation.

Calling an App from a Journey

In your Journey you can call an app function like this:

card GetWeather do
# Calls the 'get_forecast' function in the 'weather_app'
weather_data = app("weather_app", "get_forecast", ["Cape Town"])

# The result is available in the 'weather_data' variable
text("The weather in Cape Town is: @(weather_data.result.temperature)°C")
end

This triggers a journey_event in your Lua app.

Handling a journey_event

Your app must handle the journey_event and can control the flow of the Journey by its return value.

Synchronous Flow: continue

For operations that complete instantly, return "continue" along with the result. The Journey will proceed to the next block without pausing.

  • Use cases: Data validation, simple calculations, formatting text.
  • Return signature: return "continue", result_table
-- In your on_event function
elseif event == "journey_event" and data.function_name == "add" then
local sum = tonumber(data.args[1]) + tonumber(data.args[2])
-- The Journey continues immediately with the result
return "continue", { value = sum }
end

Asynchronous Flow: wait and turn.leases

For long-running tasks, like waiting for a payment confirmation webhook, you can tell the Journey to pause by returning "wait".

The Journey will remain paused until your app explicitly resumes it by sending data to its lease. A lease is a temporary hold on the Journey's state, identified by the chat_uuid.

  • Use cases: Waiting for webhooks, human approvals, or timed delays.
  • Return signature: return "wait"

Example Workflow: Waiting for a Payment Webhook

  1. Journey Initiates and Waits: The Journey calls your app, which initiates a payment and tells the Journey to wait.

    -- journey_event handler
    if data.function_name == "waitForPayment" then
    -- The app might call an external payment API here
    -- ...
    -- Now, tell the Journey to pause
    return "wait"
    end
  2. External Webhook Arrives: Later, your payment provider sends a webhook to your app's HTTP endpoint. Your app's http_request handler parses it.

  3. App Resumes the Journey: Inside the http_request handler, you use turn.leases.send_input() with the original chat_uuid to wake up the correct Journey and deliver the result.

    -- http_request handler
    -- Assume you got the chat_uuid from the webhook's metadata
    local chat_uuid = webhook_payload.metadata.chat_uuid
    local result_data = {
    payment_confirmed = true,
    transaction_id = "txn_123"
    }
    turn.leases.send_input(chat_uuid, result_data)

The Journey receives the result_data and automatically resumes execution.

AI Agent Skills

Lua apps can also expose skills—functions that AI agents call during conversations to retrieve data or perform actions. Skills use a simple LDoc format and have access to the full Turn Lua API. See AI Agent Skills for details.

Understanding the journey_event Data

The data parameter in your journey_event handler contains important context:

elseif event == "journey_event" then
-- data contains:
-- data.function_name - The function being called (e.g., "calculate", "fetch_data")
-- data.args - Array of arguments passed from the Journey (already evaluated)
-- data.chat_uuid - UUID of the chat (may be nil in simulator)
-- data.contact_uuid - UUID of the contact (may be nil in simulator)

local function_name = data.function_name
local args = data.args or {}
local chat_uuid = data.chat_uuid -- Store this for async operations!

if function_name == "process_order" then
local order_id = args[1]
local amount = args[2]
-- Process and return result
return "continue", { order_id = order_id, status = "processed" }
end
end
Looking up the contact

Use data.contact_uuid with turn.contacts.get() to look up the contact directly from the database, bypassing Elasticsearch. This avoids any eventual consistency concerns:

local contact, found = turn.contacts.get(data.contact_uuid)
if found then
local name = contact.details.name
end

Error Handling in journey_event

Proper error handling ensures your Journey can gracefully handle both expected and unexpected issues:

elseif event == "journey_event" then
local function_name = data.function_name

if function_name == "validate_input" then
local input = data.args[1]

-- Business logic error (expected, Journey continues)
if not input or input == "" then
return "continue", { valid = false, error = "Input cannot be empty" }
end

-- Success case
return "continue", { valid = true, processed_input = string.upper(input) }

elseif function_name == "external_api_call" then
-- System error (unexpected, Journey should handle error path)
local response, status = turn.http.request({
url = "https://api.example.com/data",
method = "GET"
})

if status ~= 200 then
return "error", "API call failed with status: " .. status
end

return "continue", turn.json.decode(response)
else
-- Unknown function
return "continue", {
success = false,
message = "Unknown function: " .. function_name
}
end
end

Routing Contacts to Other Journeys

Your app can route a contact to a different journey directly from a journey_event handler using turn.journeys.start(). This is useful when your app needs to decide which journey a contact should enter based on external logic (e.g., A/B testing, eligibility checks, tiered support).

elseif event == "journey_event" and data.function_name == "route_contact" then
local chat_uuid = data.chat_uuid
local contact_uuid = data.contact_uuid

-- Determine which journey to start (e.g., from app config or an API call)
local target_journey_uuid = determine_target_journey()

-- Optionally update contact fields before routing
local contact, found = turn.contacts.get(contact_uuid)
if found then
turn.contacts.update_contact_details(contact, {
routed_to = "experiment_arm_a"
})
end

-- Start the target journey, overriding the current one
local result, ok = turn.journeys.start(chat_uuid, target_journey_uuid, { override = true })

if ok then
return "continue", { routed = true }
else
return "continue", { routed = false, error = result }
end
end
Calling with override = true terminates the current journey

When you call turn.journeys.start() with override = true from inside a journey_event handler, the calling journey's lease is released immediately and the calling journey is terminated. The new journey starts asynchronously. No state is passed from the calling journey to the new one — use contact fields or turn.leases.update_metadata() if you need to carry data across.

Passing Configuration to Journeys

Apps can write configuration data that journeys read at runtime using data tables. This avoids hardcoding values into journey notebooks.

Writing data from your app

Use turn.data.dictionary to store key-value configuration:

-- Write experiment config that the journey can read
turn.data.dictionary.set("evidential", "experiment_id", "exp_12345")
turn.data.dictionary.set("evidential", "arm_a_journey", "d58b0319-eb3f-4884-b43b-4ef0b7c37e1d")

Reading data in journeys

Journeys access data table values via expressions. Dictionary values are available under the namespace you chose:

card RouteContact do
text("Your experiment: @(evidential.experiment_id)")
end

Dynamic key access

Journey expressions support dynamic map key access using the @(map[key]) syntax. This lets you look up values using contact fields or other variables at runtime:

card RouteContact do
# Look up the journey UUID for this contact's assigned arm
target_journey = "@(arm_journeys[contact.assigned_arm])"
text("Routing to: @target_journey")
end
Don't use @ inside brackets

Both @map[key] and @(map[key]) work. However, do not add an extra @ inside the brackets — @map[@key] will fail because the outer @ already opens the expression context.

Managing App-Created Journeys

If your app creates journeys programmatically (e.g., via turn.manifest.install()), use journey mappings to track the installed journey UUIDs:

-- After installing a journey
local result, ok = turn.manifest.install(manifest)
if ok then
-- The mapping is set automatically by manifest.install()
-- Retrieve it later:
local journey_uuid = turn.app.get_journey_mapping("my_routing_journey")
end

This prevents orphaned journeys from accumulating when configuration changes. Always check for an existing mapping before creating a new journey:

local existing_uuid = turn.app.get_journey_mapping("my_journey")
if existing_uuid then
-- Journey already exists, update config instead of creating a new one
turn.data.dictionary.set("my_namespace", "setting", new_value)
else
-- First time: create the journey
local result, ok = turn.manifest.install(manifest)
end