API Reference
Complete reference for all turn.* APIs available in Lua apps.
API Availability
The APIs documented here are available in different contexts:
| Context | Available APIs |
|---|---|
| Lua Apps (full apps) | All APIs documented on this page |
| App Skills (bundled with apps) | All APIs documented on this page |
| Code Skills (UI-created) | Only: turn.http, turn.json, turn.crypto, turn.encoding, turn.logger |
- Code Skills (created in UI): Best for simple, stateless operations like calculations, API lookups, and data transformations. Limited to 5 APIs but easy to create and manage.
- App Skills (bundled with apps): Best for complex operations requiring logging, contact management, persistent configuration, or platform integration. Requires creating a Lua app package.
See the AI Agent Skills documentation for details on creating skills.
Core APIs
APIs for app management and configuration.
turn.app
Manage your app's configuration, contact field subscriptions, and asset mappings. This is the new, preferred API.
Configuration Management
get_config(): Returns a table of the entire configuration (always fetches latest from database).get_config_value(key): Returns the value for a specific key.update_config(updates): Merges a table of updates into the existing config.set_config(new_config): Replaces the entire configuration.
-- Get full configuration
local config = turn.app.get_config()
-- Get specific value
local api_key = turn.app.get_config_value("api_key")
if not api_key then
turn.logger.error("API key is not configured!")
return false
end
-- Update configuration (merge)
turn.app.update_config({ last_sync = os.time() })
-- Replace entire configuration
turn.app.set_config({ api_key = "new_key", webhook_url = "https://example.com" })
Contact Field Subscriptions
get_contact_subscriptions(): Gets the current list of subscribed fields.set_contact_subscriptions(fields): Sets the list of contact fields to subscribe to.fields(table): A Lua array of field names (e.g.,{"name", "age"}).- Returns
success, reason- check success before continuing.
-- Set subscriptions on install or config change
if event == "install" or event == "config_changed" then
local success, reason = turn.app.set_contact_subscriptions({"name", "surname", "email"})
if not success then
turn.logger.error("Failed to set subscriptions: " .. reason)
return false
end
end
-- Get current subscriptions
local fields = turn.app.get_contact_subscriptions()
for _, field in ipairs(fields) do
turn.logger.info("Subscribed to: " .. field)
end
-- Unsubscribe from all fields
turn.app.set_contact_subscriptions({})
Asset and Journey Mapping (Advanced)
For apps that bundle assets or journeys, these functions maintain UUID mappings:
set_asset_mapping(mapping): Maps asset placeholders to their installed UUIDs.get_asset_mapping(): Returns the current asset mapping table.get_asset_uuid(placeholder): Gets the UUID for a specific asset.set_journey_mapping(mapping): Maps journey files to their installed UUIDs.get_journey_mapping(): Returns the current journey mapping table.
-- During installation, if your app includes bundled assets
turn.app.set_asset_mapping({
["logo.png"] = "uuid-1234-5678",
["template.html"] = "uuid-abcd-efgh"
})
-- Later, when you need the asset
local logo_uuid = turn.app.get_asset_uuid("logo.png")
turn.assets
Load static files (like templates or images) from an assets/ folder in your app's .zip file.
list(directory_path): Lists files in a directory.directory_path(string, optional): The path withinassets/.
exists(asset_path): Checks if a file exists.asset_path(string): The full path to the asset.
load(asset_path): Loads the content of an asset.asset_path(string): The full path to the asset.
local journey_files = turn.assets.list("journeys")
for _, filename in ipairs(journey_files) do
local content = turn.assets.load("journeys/" .. filename)
turn.logger.info("Loaded journey template: " .. filename)
end
turn.configuration
This API is deprecated and will be removed in a future version. Please use turn.app instead. All functions in this module will log a warning to your app's logs.
turn.manifest
Standardized API for manifest-based app installation and uninstallation. This API handles installation of contact fields, media assets, and journeys defined in your app's manifest.json file.
turn.manifest.install(manifest)
Installs an app based on its manifest. The installation process:
- Creates all contact fields
- Installs all media assets and tracks their IDs
- Creates all journeys in two passes (create disabled, then link and enable)
- Creates all templates (skipped if same name+language already exists)
- Creates all WhatsApp flows (skipped if PUBLISHED, updated if DRAFT)
Parameters:
manifest(table): The manifest table (typically loaded frommanifest.json)
Returns: A table with installation results:
{
success = true|false,
app = {...}, -- App metadata from manifest
contact_fields = {
total = number,
created = number,
failed = {field_name1, field_name2, ...}
},
media_assets = {
total = number,
created = number,
failed = {filename1, filename2, ...}
},
journeys = {
total = number,
created = number,
failed = {journey_name1, journey_name2, ...}
},
templates = {
total = number,
created = number,
skipped = number, -- Templates that already existed
failed = {"name:language", ...}
},
flows = {
total = number,
created = number,
updated = number, -- DRAFT flows that were updated
skipped = number, -- PUBLISHED flows that already existed
failed = {flow_name1, flow_name2, ...}
}
}
Manifest Structure: Your manifest.json can include:
{
"app": {
"name": "my-app",
"version": "1.0.0"
},
"contact_fields": [
{
"type": "STRING",
"name": "customer_id",
"display": "Customer ID",
"private": false
}
],
"media_assets": [
{
"asset_path": "images/logo.png",
"filename": "logo.png",
"content_type": "image/png",
"description": "Company logo"
}
],
"journeys": [
{
"name": "Welcome Journey",
"file": "welcome.md",
"description": "Welcomes new users"
}
],
"templates": [
{
"name": "welcome_message",
"file": "welcome_message.json"
}
],
"flows": [
{
"name": "signup_flow",
"file": "signup.json",
"categories": ["SIGN_UP"],
"publish": false
}
]
}
Asset File Locations: Each asset type is loaded from a specific subdirectory within assets/:
| Asset Type | Manifest Field | File Location |
|---|---|---|
| Media assets | asset_path: "images/logo.png" | assets/images/logo.png |
| Journeys | file: "welcome.md" | assets/journeys/welcome.md |
| Templates | file: "welcome_message.json" | assets/templates/welcome_message.json |
| Flows | file: "signup.json" | assets/flows/signup.json |
For example, with this directory structure:
my_app/
└── assets/
├── manifest.json
├── images/
│ └── logo.png
├── journeys/
│ └── welcome.md
├── templates/
│ └── welcome_message.json
└── flows/
└── signup.json
Template File Format: Each template JSON file should contain the full template definition:
{
"language": "en",
"category": "UTILITY",
"components": [
{ "type": "BODY", "text": "Hello {{1}}, welcome to our service!" }
]
}
Note: The name field from the manifest takes precedence over any name in the file.
Placeholder Replacement: When creating journeys that reference other journeys or media assets, use placeholders:
- Media:
asset:filename.png- Will be replaced with the actual external_id - Journeys:
journey:journey-file.md- Will be replaced with the actual journey UUID
Example Usage:
function App.on_event(app, number, event, data)
if event == "install" then
-- Load manifest from assets
local manifest_json = turn.assets.load("manifest.json")
local manifest = turn.json.decode(manifest_json)
-- Install everything
local results = turn.manifest.install(manifest)
if results.success then
turn.logger.info("Installation completed successfully!")
turn.logger.info(string.format(
"Contact fields: %d/%d created",
results.contact_fields.created,
results.contact_fields.total
))
turn.logger.info(string.format(
"Media assets: %d/%d installed",
results.media_assets.created,
results.media_assets.total
))
turn.logger.info(string.format(
"Journeys: %d/%d created",
results.journeys.created,
results.journeys.total
))
else
turn.logger.error("Installation completed with errors")
if #results.contact_fields.failed > 0 then
turn.logger.error("Failed contact fields: " ..
table.concat(results.contact_fields.failed, ", "))
end
end
return results.success
end
end
turn.manifest.uninstall(manifest, options)
Uninstalls an app based on its manifest. By default, this performs a safe uninstall that preserves user data (contact fields).
Parameters:
manifest(table): The manifest table (typically loaded frommanifest.json)options(table, optional): Configuration optionsremove_contact_fields(boolean): Iftrue, removes contact fields (default:false)
Returns: A table with uninstallation results:
{
success = true|false,
app = {...}, -- App metadata from manifest
contact_fields = {
removed = number,
skipped = boolean, -- true if contact fields were preserved
failed = {field_name1, field_name2, ...}
},
media_assets = {
count = number,
removed = 0,
note = "Media assets are preserved and can be manually deleted if needed"
},
journeys = {
removed = number,
failed = {journey_name1, journey_name2, ...}
},
templates = {
count = number,
removed = 0,
note = "Templates are preserved as they require Meta approval and may be used elsewhere"
},
flows = {
count = number,
removed = 0,
note = "Flows are preserved as they cannot be deleted via API and may be used elsewhere"
}
}
Example Usage:
function App.on_event(app, number, event, data)
if event == "uninstall" then
-- Load manifest from assets
local manifest_json = turn.assets.load("manifest.json")
local manifest = turn.json.decode(manifest_json)
-- Safe uninstall (preserves contact fields)
local results = turn.manifest.uninstall(manifest)
if results.success then
turn.logger.info("Uninstallation completed successfully!")
turn.logger.info(string.format(
"Journeys removed: %d",
results.journeys.removed
))
if results.contact_fields.skipped then
turn.logger.info("Contact fields: Preserved (user data retained)")
end
else
turn.logger.error("Uninstallation completed with errors")
end
return results.success
end
end
Full Uninstall (removes everything including user data):
-- Remove everything, including contact fields
local results = turn.manifest.uninstall(manifest, {
remove_contact_fields = true
})
Important Notes:
- By default, contact fields are preserved to protect user data
- Media assets are always preserved (they can be reused by other apps)
- Templates are always preserved (they require Meta approval and may be used by other flows/apps)
- Journeys are removed using a two-phase process (clear content, then delete)
- Only journeys that match the manifest names are removed
Communication APIs
APIs for external communication and platform interaction.
turn.apps
Call functions in other installed Lua apps on the same number. This enables modular app architectures where specialized apps can expose reusable functionality.
call(app_name, function_name, arguments): Calls a function in another app.app_name(string): The name of the target app (as defined in its manifest).function_name(string): The function to call in the target app.arguments(table): Arguments to pass to the function. Must be a table (use{}for no arguments).- Returns
success, result- a boolean indicating success and the function result or error message.
-- Call a function in another app with arguments
local success, result = turn.apps.call("fhir_app", "create_appointment", {"2024-01-15", "10:00"})
if success then
turn.logger.info("Appointment created: " .. tostring(result))
else
turn.logger.error("Failed to create appointment: " .. turn.json.encode(result))
end
-- Call with no arguments (must still pass empty table)
local success, data = turn.apps.call("analytics_app", "get_daily_stats", {})
Handling calls in the target app:
The target app receives cross-app calls as journey_event events with function_name and args in the data:
function App.on_event(app, number, event, data)
if event == "journey_event" then
local fn = data.function_name
local args = data.args or {}
if fn == "create_appointment" then
local date, time = args[1], args[2]
-- Process and return result
return "continue", { appointment_id = "APT-12345" }
elseif fn == "get_daily_stats" then
return "continue", { visits = 150, conversions = 23 }
end
end
end
Security Notes:
- Apps can only call other apps installed on the same number
- Cross-number calls are denied for security reasons
- Async functions (those returning
wait) cannot be called from other apps
turn.contacts
Find contacts and manage their custom fields.
find(query): Finds a contact.query(table): Key-value pairs to search by (e.g.,{ msisdn = "+27..." }).
update_contact_details(contact, details): Updates a contact's fields.contact(table): The contact object fromfind().details(table): Key-value pairs of fields to update.
create_contact_field(field_def): Creates a new custom field in the schema.field_def(table): A table withtype,name, anddisplaykeys.
local contact, found = turn.contacts.find({ msisdn = "+27820000000" })
if found then
turn.contacts.update_contact_details(contact, { loyalty_id = "LTY-12345" })
end
turn.http
Make external HTTP requests.
This API is available in UI-created Code Skills, making it possible to call external APIs from simple skills without creating a full Lua app.
request(options): Sends an HTTP request.options(table): A table withurl,method,headers(table),body(string), andtimeout(number, milliseconds).- Returns four values:
body(string),status_code(number),headers(table),status_line(string).
local body, status_code, headers = turn.http.request({
url = "https://api.example.com/v1/events",
method = "POST",
headers = { ["Content-Type"] = "application/json" },
body = turn.json.encode({ message = "Hello" })
})
Common HTTP Patterns
GET request with authentication:
local body, status_code = turn.http.request({
url = "https://api.example.com/users/" .. user_id,
method = "GET",
headers = {
["Authorization"] = "Bearer " .. api_token,
["Accept"] = "application/json"
}
})
if status_code == 200 then
return turn.json.decode(body)
else
return { error = "API request failed", status = status_code }
end
POST request with JSON body:
local payload = turn.json.encode({
event = "order_placed",
order_id = order_id,
customer = context.contact.name
})
local body, status_code = turn.http.request({
url = "https://api.example.com/webhooks",
method = "POST",
headers = {
["Content-Type"] = "application/json",
["X-API-Key"] = api_key
},
body = payload
})
Error handling pattern:
local body, status_code = turn.http.request({
url = "https://api.example.com/data",
method = "GET",
timeout = 5000
})
if status_code >= 400 then
return { error = "API error", status = status_code }
end
return turn.json.decode(body)
turn.journeys
Programmatically manage Journeys.
create(journey_def): Creates a new Journey.journey_def(table): A table withname,notebook, andenabled.
update(journey_uuid, updates): Updates an existing Journey.journey_uuid(string): The UUID of the journey to update.updates(table): A table withname,notebook, orenabled.
delete(journey_def): Deletes a Journey by name.journey_def(table): A table with thenameof the journey.
list(): Returns a list of all Journeys.
local journey, ok = turn.journeys.create({
name = "New User Onboarding",
notebook = turn.assets.load("journeys/onboarding.md"),
enabled = true
})
turn.templates
Create and manage WhatsApp message templates.
get(name, language): Gets a template by name and language.name(string): The template name.language(string): The language code (e.g.,"en","pt_BR").
list(): Returns a list of all templates for the current number.exists(name, language): Checks if a template exists.name(string): The template name.language(string): The language code.- Returns
trueif the template exists,falseotherwise.
create(template_def): Creates a new template.template_def(table): A table withname,language,category, andcomponents.- Returns
template, success- the template object and a boolean indicating success. - If a template with the same name+language already exists, returns the existing template (idempotent).
- New templates start with status
"PENDING"awaiting Meta approval.
-- Check if template exists
if not turn.templates.exists("welcome_message", "en") then
-- Create a new template
local template, success = turn.templates.create({
name = "welcome_message",
language = "en",
category = "UTILITY", -- AUTHENTICATION, MARKETING, or UTILITY
components = {
{ type = "HEADER", format = "TEXT", text = "Welcome to {{1}}" },
{ type = "BODY", text = "Hello {{1}}, thanks for joining!" },
{ type = "FOOTER", text = "Reply STOP to unsubscribe" },
{ type = "BUTTONS", buttons = {
{ type = "QUICK_REPLY", text = "Get Started" }
}}
}
})
if success then
turn.logger.info("Created template: " .. template.name)
-- template.status will be "PENDING" until Meta approves
else
turn.logger.error("Failed to create template: " .. template)
end
end
-- Get an existing template
local template = turn.templates.get("welcome_message", "en")
-- List all templates
local templates = turn.templates.list()
for _, t in ipairs(templates) do
turn.logger.info(t.name .. " (" .. t.language .. "): " .. t.status)
end
Component Types:
HEADER: Optional header withformat(TEXT,IMAGE,VIDEO,DOCUMENT,LOCATION) andtextfor TEXT formatBODY: Required message body withtextcontaining the main contentFOOTER: Optional footer withtextBUTTONS: Optional buttons array withtype(QUICK_REPLY,URL,PHONE_NUMBER,COPY_CODE,FLOW,VOICE_CALL)
Categories:
AUTHENTICATION: For one-time passwords and verificationMARKETING: For promotional contentUTILITY: For transactional messages (confirmations, updates, etc.)
turn.flows
Create and manage WhatsApp Flows for structured interactions.
get(name): Gets a flow by name.name(string): The flow name.- Returns the flow object or
nilif not found.
list(): Returns a list of all flows for the current WABA.exists(name): Checks if a flow exists.name(string): The flow name.- Returns
trueif the flow exists,falseotherwise.
create(flow_def): Creates a new flow.flow_def(table): A table withname,json, and optionalcategories.- Returns
flow, success- the flow object and a boolean indicating success. - If a flow with the same name already exists, returns the existing flow (idempotent).
- New flows are created with status
"DRAFT"and must be published via Meta's tools.
-- Check if flow exists
if not turn.flows.exists("signup_flow") then
-- Create a new flow
local flow, success = turn.flows.create({
name = "signup_flow",
json = '{"version":"5.0","screens":[...]}',
categories = { "SIGN_UP" } -- Optional, defaults to { "OTHER" }
})
if success then
turn.logger.info("Created flow: " .. flow.name .. " (ID: " .. flow.id .. ")")
-- flow.status will be "DRAFT" - publish via Meta's tools
else
turn.logger.error("Failed to create flow: " .. flow)
end
end
-- Get an existing flow
local flow = turn.flows.get("signup_flow")
-- List all flows
local flows = turn.flows.list()
for _, f in ipairs(flows) do
turn.logger.info(f.name .. ": " .. f.status)
end
Flow Statuses:
DRAFT: Flow is being developed, can be updatedPUBLISHED: Flow is live and can be used in conversationsDEPRECATED: Flow has been marked for retirementBLOCKED: Flow has been blocked by MetaTHROTTLED: Flow is rate-limited
Categories:
SIGN_UP,SIGN_IN,APPOINTMENT_BOOKING,LEAD_GENERATION,CONTACT_US,CUSTOMER_SUPPORT,SURVEY,OTHER
turn.leases
Used to resume waiting Journeys. See the asynchronous flow section for a detailed example.
send_input(chat_uuid, input_data): Sends data to a paused Journey, resuming it.chat_uuid(string): The UUID of the chat whose Journey is waiting.input_data(any): The data to send as the result of theapp()block.
turn.leases.send_input(chat_uuid, { payment_status = "confirmed" })
Data & Utilities
APIs for data processing, security, and utilities.
turn.json
Encode and decode JSON data.
This API is available in UI-created Code Skills.
encode(data, options): Encodes a Lua table into a JSON string.data(table): The Lua table to encode.options(table, optional): e.g.,{ indent = true }.
decode(json_string): Decodes a JSON string into a Lua table.json_string(string): The string to decode.
local my_table = { name = "John Doe", age = 30 }
local json_string = turn.json.encode(my_table)
-- Decode JSON string back to table
local data = turn.json.decode(json_string)
turn.encoding
Encode and decode binary data in various formats.
This API is available in UI-created Code Skills.
Base64 Encoding
Standard base64 encoding for embedding binary data in JSON or text formats.
base64_encode(data): Encodes binary data to base64 string with standard characters (+, /) and padding (=).base64_decode(encoded): Decodes base64 string to binary data. Throws error on invalid input (catch withpcall).
-- Encode image data for JSON
local image_data = turn.assets.load("images/logo.png")
local base64_image = turn.encoding.base64_encode(image_data)
-- Send in HTTP request
turn.http.request({
url = "https://api.example.com/upload",
method = "POST",
body = turn.json.encode({ image = base64_image })
})
-- Decode base64 back to binary
local decoded = turn.encoding.base64_decode(encoded_data)
URL-Safe Base64 Encoding
URL-safe variant using - and _ instead of + and /, without padding. Ideal for tokens in URLs and query parameters.
base64_url_encode(data): Encodes to URL-safe base64 (no padding).base64_url_decode(encoded): Decodes URL-safe base64. Throws error on invalid input.
-- Create URL-safe token
local session_data = turn.json.encode({ user_id = 123, timestamp = os.time() })
local token = turn.encoding.base64_url_encode(session_data)
-- Use in URL: https://example.com/verify?token=<token>
Hexadecimal Encoding
Convert binary data to/from hexadecimal strings. Useful for displaying hashes and debugging.
hex_encode(data): Encodes binary data to lowercase hex string.hex_decode(hex_string): Decodes hex string to binary. Accepts mixed case. Throws error on invalid input.
-- Display hash in hex format
local hash = turn.crypto.sha256("important data")
local hex_hash = turn.encoding.hex_encode(hash)
turn.logger.info("Data hash: " .. hex_hash)
-- Decode hex
local binary = turn.encoding.hex_decode("48656c6c6f")
-- Returns: "Hello"
URL Encoding (RFC 3986)
Percent-encode strings for use in URLs. Spaces become %20. Use for query parameter values and path segments.
url_encode(string): Encodes all characters except unreserved characters (letters, digits,-,.,_,~).url_decode(encoded): Decodes percent-encoded sequences back to original characters.
-- Encode a value for use in a URL
local encoded = turn.encoding.url_encode("hello world+test")
-- Returns: "hello%20world%2Btest"
-- Decode back to original
local decoded = turn.encoding.url_decode(encoded)
-- Returns: "hello world+test"
Form URL Encoding (x-www-form-urlencoded)
Encode strings for HTTP POST bodies with Content-Type: application/x-www-form-urlencoded. Spaces become + instead of %20.
form_encode(string): Encodes a string with spaces as+.form_decode(encoded): Decodes both%xxsequences and+as space.
-- Encode for form submission
local encoded = turn.encoding.form_encode("hello world")
-- Returns: "hello+world"
-- Decode form data
local decoded = turn.encoding.form_decode("hello+world")
-- Returns: "hello world"
Query String Encoding
Encode and decode entire query strings as tables.
encode_query(params): Encodes a table to a query string using RFC 3986 (spaces as%20).encode_form(params): Encodes a table for form POST bodies (spaces as+).decode_query(query_string): Decodes a query string to a table. Handles both%20and+as spaces.
-- Encode a table to a query string (RFC 3986)
local query = turn.encoding.encode_query({
name = "John Doe",
phone = "+1234567890"
})
-- Returns: "name=John%20Doe&phone=%2B1234567890"
-- Encode for form POST body (spaces as +)
local body = turn.encoding.encode_form({
grant_type = "authorization_code",
code = "abc123"
})
-- Returns: "code=abc123&grant_type=authorization_code"
-- Decode a query string to a table
local params = turn.encoding.decode_query("name=John%20Doe&age=30")
-- Returns: {name = "John Doe", age = "30"}
OAuth Example:
-- Build authorization URL with query parameters
local auth_params = {
client_id = config.client_id,
redirect_uri = "https://example.com/callback",
response_type = "code",
scope = "openid profile"
}
local auth_url = "https://auth.example.com/authorize?" .. turn.encoding.encode_query(auth_params)
-- Exchange code for tokens with form-encoded body
local token_params = {
grant_type = "authorization_code",
code = auth_code,
client_id = config.client_id,
client_secret = config.client_secret
}
local body, status_code = turn.http.request({
url = "https://auth.example.com/token",
method = "POST",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
body = turn.encoding.encode_form(token_params)
})
turn.crypto
Cryptographic operations for secure hashing, HMAC, and random generation.
This API is available in UI-created Code Skills.
All cryptographic operations use Erlang's :crypto module with industry-standard algorithms. The HMAC verification uses constant-time comparison to prevent timing attacks.
HMAC Signature Generation
Generate HMAC signatures for webhook verification and API authentication.
hmac_sha256(key, message): Returns HMAC-SHA256 as binary.hmac_sha256_hex(key, message): Returns HMAC-SHA256 as hex string.hmac_sha256_base64(key, message): Returns HMAC-SHA256 as base64.hmac_sha512(key, message): Returns HMAC-SHA512 as binary.hmac_sha512_hex(key, message): Returns HMAC-SHA512 as hex string.hmac_sha512_base64(key, message): Returns HMAC-SHA512 as base64.
-- Generate webhook signature
local secret = turn.app.get_config_value("webhook_secret")
local payload = turn.json.encode({ event = "payment.completed" })
local signature = turn.crypto.hmac_sha256_hex(secret, payload)
-- Send to external service
turn.http.request({
url = "https://partner.com/webhook",
method = "POST",
headers = {
["X-Signature"] = signature
},
body = payload
})
HMAC Signature Verification
Verify webhook signatures from external services (Stripe, GitHub, PayPal, etc.).
verify_hmac_sha256(key, message, expected_signature): Verifies HMAC-SHA256 signature using constant-time comparison. Returnstrueif valid,falseotherwise. Accepts signature as hex or binary.
-- Verify Stripe webhook
function App.on_event(app, number, event, data)
if event == "http_request" then
local signature = data.headers["Stripe-Signature"]
local webhook_secret = turn.app.get_config_value("stripe_webhook_secret")
-- Constant-time verification prevents timing attacks
if not turn.crypto.verify_hmac_sha256(webhook_secret, data.body, signature) then
turn.logger.warn("Invalid webhook signature")
return true, { status = 401, body = "Invalid signature" }
end
-- Process verified webhook
local event_data = turn.json.decode(data.body)
-- ... handle event ...
return true, { status = 200, body = "OK" }
end
end
Cryptographic Hashing
Generate hashes for data integrity and checksums.
sha256(data): Returns SHA-256 hash as binary.sha256_hex(data): Returns SHA-256 hash as hex string.md5(data): Returns MD5 hash as binary.md5_hex(data): Returns MD5 hash as hex string.
-- Generate content hash
local content = "Important data to track"
local hash = turn.crypto.sha256_hex(content)
turn.logger.info("Content hash: " .. hash)
-- Store hash for later verification
turn.app.update_config({ last_sync_hash = hash })
Secure Random Generation
Generate cryptographically secure random values for tokens, session IDs, and nonces.
random_bytes(length): Generates random bytes (max 1024). Uses:crypto.strong_rand_bytes/1.random_string(length): Generates URL-safe random string (max 1024). Perfect for tokens.
-- Generate session token
local session_token = turn.crypto.random_string(32)
turn.app.update_config({ session_token = session_token })
-- Generate API key
local api_key = turn.crypto.random_string(64)
-- Generate random bytes for encryption key
local encryption_key = turn.crypto.random_bytes(32)
local key_hex = turn.encoding.hex_encode(encryption_key)
AES-GCM Authenticated Encryption
Encrypt and decrypt sensitive data using AES-256-GCM, providing both confidentiality and integrity. Ideal for session tokens, cookies, and sensitive configuration data.
aes_gcm_encrypt(plaintext, key): Encrypts data with AES-256-GCM.plaintext(string): The data to encrypt.key(string): Must be exactly 32 bytes (usesha256()to derive from secrets).- Returns: Base64-encoded ciphertext containing nonce, encrypted data, and authentication tag.
aes_gcm_encrypt(plaintext, key, aad): Encrypts with Additional Authenticated Data.aad(string): Context data that must match during decryption (e.g., user ID).
aes_gcm_decrypt(ciphertext, key): Decrypts AES-256-GCM encrypted data.ciphertext(string): Base64-encoded ciphertext fromaes_gcm_encrypt().key(string): Must be exactly 32 bytes.- Returns:
plaintext, nilon success, ornil, error_messageon failure.
aes_gcm_decrypt(ciphertext, key, aad): Decrypts with AAD verification.
-- Derive a 32-byte key from a secret
local key = turn.crypto.sha256(turn.app.get_config_value("encryption_secret"))
-- Encrypt sensitive data
local encrypted = turn.crypto.aes_gcm_encrypt("secret data", key)
turn.logger.info("Encrypted: " .. encrypted)
-- Decrypt data
local plaintext, err = turn.crypto.aes_gcm_decrypt(encrypted, key)
if err then
turn.logger.error("Decryption failed: " .. err)
else
turn.logger.info("Decrypted: " .. plaintext)
end
-- Using AAD to bind ciphertext to context (e.g., user ID)
-- This prevents token replay attacks across different users
local user_id = "user-123"
local session = turn.json.encode({ token = "abc", expires = os.time() + 3600 })
local encrypted_session = turn.crypto.aes_gcm_encrypt(session, key, user_id)
-- Decryption will fail if AAD doesn't match
local data, err = turn.crypto.aes_gcm_decrypt(encrypted_session, key, user_id)
if err then
turn.logger.warn("Session verification failed: " .. err)
return false
end
Security Notes:
- Each encryption generates a unique random nonce (no nonce reuse)
- GCM provides both confidentiality and integrity (authenticated encryption)
- Decryption fails if the ciphertext is tampered with, the key is wrong, or AAD doesn't match
- Use AAD to bind ciphertext to context when appropriate (prevents replay attacks)
- Never hardcode keys - store secrets securely in app configuration
turn.i18n
Internationalization (i18n) support for multi-language apps using PO files. Translations are loaded from assets/locales/{locale}/LC_MESSAGES/{domain}.po in your app's ZIP archive.
Translation Functions
t(msgid): Translates a string using the default locale and "messages" domain.t(msgid, opts): Translates with options:locale(string, optional): Override the target localedomain(string, optional): Use a specific translation domain (default: "messages")- Any other keys are used as interpolation bindings for
%{key}placeholders
-- Simple translation using default locale
turn.i18n.t("Hello")
-- Override locale for this call
turn.i18n.t("Hello", {locale = "spa"})
-- Use a specific domain
turn.i18n.t("Invalid input", {domain = "errors"})
-- With interpolation bindings
turn.i18n.t("Hello %{name}", {name = "Maria"})
-- Combined: locale, domain, and bindings
turn.i18n.t("Welcome %{name}", {locale = "por_BR", name = "João"})
Locale-Bound Translator
for_locale(locale): Creates a translator function bound to a specific locale. Recommended for per-contact translations.for_locale(locale, opts): Creates a bound translator with default domain:domain(string, optional): Default translation domain for this translator
-- Create a translator bound to the contact's language
local t = turn.i18n.for_locale(contact.language)
t("Hello") -- Uses bound locale
t("Hello %{name}", {name = "Maria"}) -- With interpolation
t("Goodbye", {locale = "eng"}) -- Can still override per-call
-- With bound domain for error messages
local t_errors = turn.i18n.for_locale(contact.language, {domain = "errors"})
t_errors("Invalid input") -- Uses bound locale and "errors" domain
t_errors("Other", {domain = "messages"}) -- Can override domain per-call
Translation File Structure
PO files should be placed in assets/locales/{locale}/LC_MESSAGES/{domain}.po:
assets/
└── locales/
├── eng/
│ └── LC_MESSAGES/
│ ├── messages.po # Default domain
│ └── errors.po # Custom domain
├── spa/
│ └── LC_MESSAGES/
│ ├── messages.po
│ └── errors.po
└── por_BR/
└── LC_MESSAGES/
├── messages.po
└── errors.po
Example PO File
Create assets/locales/por_BR/LC_MESSAGES/messages.po:
# Portuguese (Brazil) translations
msgid ""
msgstr ""
"Language: por_BR\n"
msgid "Hello"
msgstr "Olá"
msgid "Hello %{name}"
msgstr "Olá %{name}"
msgid "Welcome to our service"
msgstr "Bem-vindo ao nosso serviço"
Manifest Configuration
Set the default locale in your manifest.json:
{
"app": {
"name": "my-app",
"version": "1.0.0",
"default_locale": "eng"
}
}
Language Resolution Order
- Locale bound via
for_locale()(if using bound translator) options.locale- Explicit override per-callapp.default_locale- From manifest.json"eng"- Final fallback
Locale Code Normalization
The API automatically normalizes locale codes:
- 2-letter ISO-639-1 codes are converted to 3-letter ISO-639-3 (e.g.,
"en"→"eng","pt"→"por") - Regional variants are preserved (e.g.,
"pt-BR"→"por_BR","pt_BR"→"por_BR")
turn.qrcode
Generate QR code images.
generate(options): Creates a QR code PNG.options(table): A table withdata(string) and optional keys likefilename,color,image_data.
local qr_table, ok = turn.qrcode.generate({
data = "https://www.turn.io/",
color = "#8654CD" -- Turn.io purple!
})
turn.media
Save binary data (like images or documents) as media that can be reused in messages.
save(media_data): Saves binary data as a media item.media_data(table): A table withdata(binary string),filename(string), andcontent_type(string).
local qr_table, ok = turn.qrcode.generate({ data = "..." })
if ok then
local saved, media_info = turn.media.save(qr_table)
end
turn.google
Authenticate with Google APIs.
get_access_token(service_account_json, scopes): Gets an OAuth2 token.service_account_json(string): The JSON content of the service account file.scopes(table, optional): A list of Google API scopes.
local ok, token = turn.google.get_access_token(sa_json)
if ok then
-- Use token in turn.http request
end
turn.liquid
Render Liquid templates stored in your app's assets/liquid/ directory. Supports all standard Liquid filters and tags, plus custom translation filters for i18n.
render(template_name, variables): Renders a Liquid template with variables.template_name(string): Path to template relative toassets/liquid/(e.g., "welcome.liquid").variables(table): Variables to pass to the template.
render(template_name, variables, options): Renders with additional options.options.locale(string, optional): Set the translation locale fortfilters.options.strict_variables(boolean, optional): Error on undefined variables.
-- Render a template with variables
local html = turn.liquid.render("welcome.liquid", {
name = "Alice",
message = "Welcome to our service!"
})
-- Render with locale for translations
local html = turn.liquid.render("welcome.liquid", {
name = contact.name
}, { locale = contact.language })
Template Structure
Store templates in assets/liquid/ within your app's ZIP:
my_app/
└── assets/
└── liquid/
├── welcome.liquid
├── emails/
│ └── receipt.liquid
└── partials/
├── header.liquid
└── footer.liquid
Translation Filters (t and t_plural)
Liquid templates support translation filters that use your app's PO files.
Basic Translation (t):
{% raw %}
<!-- Simple translation -->
{{ "Hello" | t }}
<!-- With interpolation bindings -->
{{ "Hello %{name}" | t: name: user.name }}
<!-- Override locale for this filter -->
{{ "Hello" | t: locale: "spa" }}
<!-- Use a specific translation domain -->
{{ "Invalid input" | t: domain: "errors" }}
{% endraw %}
Pluralization (t_plural):
The t_plural filter handles singular/plural forms based on a count:
{% raw %}
<!-- Basic plural: shows "1 item" or "5 items" -->
{{ "One item" | t_plural: "%{count} items", count: cart.size }}
<!-- With additional bindings -->
{{ "One message from %{sender}" | t_plural: "%{count} messages from %{sender}", count: messages.size, sender: contact.name }}
{% endraw %}
Both forms are translated using your PO files. For count=1, the singular form is used; otherwise the plural form is used.
Locale Resolution Order:
locale:argument on the filter (highest priority)localeoption passed torender()default_localefrom app's manifest.json"eng"(final fallback)
Including Partials
Use the render tag to include other templates:
{% raw %}
<!-- In page.liquid -->
<div class="page">
{% render 'partials/header' %}
<p>Main content</p>
{% render 'partials/footer' %}
</div>
{% endraw %}
turn.logger
Write logs that are visible in the Turn UI for debugging.
debug(message),info(message),warning(message),error(message)message(string): The log message.
turn.logger.error("Failed to connect to database: " .. err_msg)
HTTP Server API
Build web interfaces within your Lua app. The router provides Express-style routing with middleware, automatic session management, and CSRF protection.
Quick Start
Here's a minimal working example:
-- Create a router
local router = turn.http.server.router.new("My App")
-- Define routes
router:get("/")(function(request, response)
response:html("<h1>Hello World!</h1>")
end)
router:get("/api/status")(function(request, response)
response:json({ status = "ok" })
end)
-- Handle HTTP requests in your app
function App.on_event(app, number, event, data)
if event == "http_request" then
return true, router:handle(data)
end
end
turn.http.server.router
Express-style router for handling HTTP requests with middleware support.
Creating a Router
new(name): Creates a new router instance.name(string): Router name for identification.- Returns a router object.
local router = turn.http.server.router.new("My App")
Configuration
router:config(options): Configure router options.session_secret(string): Enable automatic session management with this encryption key.session_cookie(string): Cookie name for sessions (default:"session").csrf_cookie(string): Cookie name for CSRF tokens.
router:config({
session_secret = turn.app.get_config_value("session_secret"),
session_cookie = "session"
})
Route Registration
Routes are registered using HTTP method functions that return a function accepting a handler:
router:get(path)(handler): Register GET routerouter:post(path)(handler): Register POST routerouter:put(path)(handler): Register PUT routerouter:patch(path)(handler): Register PATCH routerouter:delete(path)(handler): Register DELETE route
Path parameters use :param syntax:
-- Simple route
router:get("/status")(function(request, response)
response:json({ status = "ok" })
end)
-- Route with parameters
router:get("/users/:id")(function(request, response)
local user_id = request.params.id
response:json({ user_id = user_id })
end)
-- Multiple parameters
router:get("/orgs/:org_id/users/:user_id")(function(request, response)
response:json({
org = request.params.org_id,
user = request.params.user_id
})
end)
-- POST with form data
router:post("/users")(function(request, response)
local name = request.form.name
response:set_status(201):json({ created = true, name = name })
end)
Request Object
Route handlers receive a request object with:
method(string): HTTP method (GET, POST, etc.)path(string): Request pathparams(table): Path parameters (e.g.,:idbecomesrequest.params.id)query(table): Query string parametersform(table): Form/body parametersheaders(table): Request headers as list of{name, value}tuplescookies(table): Parsed cookiessession(table): Session data (whensession_secretis configured)csrf_token(string): CSRF token for form protectionis_htmx(boolean):trueif request hasHX-Request: trueheader
Response Object
Route handlers receive a response object with chainable methods:
set_status(code): Set HTTP status codeheader(name, value): Set a header (replaces existing header with same name)add_header(name, value): Add a header (allows duplicates, e.g., for Set-Cookie)json(data): Send JSON response with proper content-typehtml(content): Send HTML response with proper content-typetext(content): Send plain text response with proper content-typeredirect(url, status?): Redirect to URL (default 302)- HTMX methods:
hx_trigger(),hx_redirect(),hx_refresh(), etc. (see HTMX Support)
-- JSON response
response:json({ message = "Success" })
-- HTML response
response:html("<h1>Hello!</h1>")
-- Plain text response
response:text("OK")
-- Redirect
response:redirect("/dashboard")
-- Custom status and headers
response:set_status(201)
:header("X-Custom", "value")
:json({ created = true })
Middleware
router:use(middleware): Registers middleware that runs before route handlers.middleware(function): Function receiving(request, response, next).- Call
next(request, response)to continue to the next middleware/handler. - Don't call
next()to short-circuit (e.g., for authentication failures).
-- Logging middleware
router:use(function(request, response, next)
turn.logger.info(request.method .. " " .. request.path)
next(request, response)
end)
-- Authentication middleware
router:use(function(request, response, next)
if not request.session.user_id then
response:set_status(401):json({ error = "Unauthorized" })
return -- Don't call next() to block the request
end
next(request, response)
end)
-- Transform request middleware
router:use(function(request, response, next)
-- Add computed properties
request.user = load_user(request.session.user_id)
next(request, response)
end)
Session Management
When you configure session_secret, the router automatically handles encrypted session cookies:
local router = turn.http.server.router.new("My App")
router:config({
session_secret = turn.app.get_config_value("session_secret")
})
-- Now use request.session like a normal table
router:post("/login")(function(request, response)
-- Just assign values - router handles encryption automatically
request.session.user_id = 123
request.session.role = "admin"
response:redirect("/dashboard")
end)
router:get("/dashboard")(function(request, response)
-- Read values directly
if not request.session.user_id then
response:redirect("/login")
return
end
response:json({ user_id = request.session.user_id })
end)
router:post("/logout")(function(request, response)
-- Clear all session data
request.session:clear()
response:redirect("/")
end)
How sessions work:
- Sessions are encrypted using AES-256-GCM and stored in cookies
- The router transparently tracks modifications using Lua metatables
- When you write to
request.session.foo, it marks the session as modified - After your handler completes, modified sessions are encrypted and saved to cookies
- Reading session data doesn't trigger a save - only modifications do
HTMX Support
The router includes built-in support for HTMX, making it easy to build dynamic UIs with HTML-over-the-wire.
Request Properties:
request.is_htmx(boolean):trueif the request includes theHX-Request: trueheader
router:post("/update")(function(request, response)
-- Check if this is an HTMX request
if request.is_htmx then
-- Return just the updated fragment
response:html("<div>Updated!</div>")
else
-- Full page for non-HTMX requests
response:redirect("/page")
end
end)
HTMX Response Methods:
All methods are chainable and set the appropriate HTMX headers:
hx_trigger(event, detail?): Trigger a client-side eventhx_trigger_after_settle(event, detail?): Trigger after DOM settleshx_trigger_after_swap(event, detail?): Trigger after swap completeshx_redirect(url): Client-side redirect (viaHX-Redirectheader)hx_refresh(): Full page refreshhx_push_url(url): Push URL to browser historyhx_replace_url(url): Replace URL in browser history (no new entry)hx_reswap(method): Override swap method (innerHTML,outerHTML, etc.)hx_retarget(selector): Override target elementhx_reselect(selector): Override selection from response
-- Trigger a client-side event with data
router:post("/item/:id")(function(request, response)
local item = update_item(request.params.id, request.form)
response:hx_trigger("itemUpdated", {id = item.id})
:html("<div class='item'>" .. item.name .. "</div>")
end)
-- HTMX-aware redirect (client-side, not 302)
router:post("/login")(function(request, response)
if authenticate(request.form) then
if request.is_htmx then
-- HTMX handles this client-side
response:hx_redirect("/dashboard"):html("")
else
-- Standard HTTP redirect
response:redirect("/dashboard")
end
else
response:set_status(401):html("<div class='error'>Invalid credentials</div>")
end
end)
-- Update browser history
router:get("/page/:num")(function(request, response)
local content = get_page_content(request.params.num)
response:hx_push_url("/page/" .. request.params.num)
:html(content)
end)
-- Change how content is swapped
router:delete("/item/:id")(function(request, response)
delete_item(request.params.id)
-- Delete the element from DOM
response:hx_reswap("delete"):html("")
end)
Difference between redirect() and hx_redirect():
response:redirect(url)sends HTTP 302 redirect - browser navigates fullyresponse:hx_redirect(url)setsHX-Redirectheader - HTMX handles navigation client-side
Use hx_redirect() for HTMX requests to avoid full page reloads and maintain HTMX's smooth transitions.
Handling Requests
router:handle(conn_data): Routes a request to the appropriate handler.conn_data(table): Connection data from HTTP event.- Returns response table with
status,headers,body. - Automatically sets CSRF cookie on all responses.
function App.on_event(app, number, event, data)
if event == "http_request" then
return true, router:handle(data)
end
end
Built-in Error Responses
The router automatically handles:
- 404 Not Found: When no route matches the path
- 405 Method Not Allowed: When path matches but method doesn't
Complete Example
Here's a complete example of a web application with authentication, CSRF protection, and session management:
local server = require("turn.http.server")
local router = require("turn.http.server.router")
-- Create router with session support
local app = router.new("User Management")
app:config({
session_secret = turn.app.get_config_value("session_secret")
})
-- Logging middleware
app:use(function(request, response, next)
turn.logger.info(request.method .. " " .. request.path)
next(request, response)
end)
-- Public routes
app:get("/")(function(request, response)
response:html(server.render(request, "home.liquid"))
end)
app:get("/login")(function(request, response)
response:html(server.render(request, "login.liquid"))
end)
app:post("/login")(function(request, response)
-- Validate CSRF
if not server.validate_csrf(request) then
response:set_status(403):json({ error = "Invalid CSRF token" })
return
end
local email = request.form.email
local password = request.form.password
-- Validate credentials (simplified)
local user = authenticate(email, password)
if user then
-- Just assign to session - router handles the rest
request.session.user_id = user.id
request.session.email = user.email
response:redirect("/dashboard")
else
response:html(server.render(request, "login.liquid", {
error = "Invalid credentials"
}))
end
end)
-- Protected routes
app:get("/dashboard")(function(request, response)
if not request.session.user_id then
response:redirect("/login")
return
end
response:html(server.render(request, "dashboard.liquid", {
user_id = request.session.user_id,
email = request.session.email
}))
end)
app:post("/logout")(function(request, response)
if server.validate_csrf(request) then
request.session:clear()
end
response:redirect("/")
end)
-- Handle HTTP requests
function App.on_event(app, number, event, data)
if event == "http_request" then
local result = app:handle(data)
return true, result
end
end
turn.http.server Utilities
Lower-level utilities for advanced use cases. Most developers will use the router instead.
Header Utilities
get_header(headers, name): Gets a header value from a headers list (case-insensitive).headers(table): Headers as list of{name, value}tuples.name(string): Header name to find.- Returns the header value or
nil.
local content_type = turn.http.server.get_header(request.headers, "content-type")
local auth_token = turn.http.server.get_header(request.headers, "Authorization")
CSRF Protection
The server uses the double-submit cookie pattern for CSRF protection. The router automatically sets the CSRF cookie, and you can validate tokens in your handlers.
validate_csrf(request): Validates the CSRF token.request(table): The parsed request object.- Returns
trueif valid,falseotherwise. - Checks for token in
X-CSRF-Tokenheader or_csrf_tokenform field.
router:post("/update")(function(request, response)
if not turn.http.server.validate_csrf(request) then
response:set_status(403):json({ error = "Invalid CSRF token" })
return
end
-- Process the request...
end)
Template Rendering
render(request, template_name, variables): Renders a Liquid template with automatic CSRF injection.request(table): The parsed request object.template_name(string): Path to template relative toassets/liquid/.variables(table, optional): Variables to pass to the template.- Automatically adds
csrf_tokenandcsrf_inputto template variables.
router:get("/form")(function(request, response)
response:html(turn.http.server.render(request, "form.liquid", {
user = current_user
}))
end)
{% raw %}
<!-- csrf_token and csrf_input are automatically available -->
<form method="POST" action="/submit">
{{ csrf_input }}
<input type="text" name="name" value="{{ user.name }}">
<button type="submit">Save</button>
</form>
<!-- For AJAX requests -->
<script>
fetch('/api/data', {
method: 'POST',
headers: {
'X-CSRF-Token': '{{ csrf_token }}',
'Content-Type': 'application/json'
},
body: JSON.stringify({ ... })
});
</script>
{% endraw %}
Session Encryption (Low-level)
For manual session handling without the router. Most use cases should use router:config({ session_secret = ... }) instead.
-
encrypt_session(data, secret): Encrypts session data.data(table): Session data to encrypt.secret(string): Encryption secret.- Returns base64-encoded encrypted string.
-
decrypt_session(encrypted, secret): Decrypts session data.encrypted(string): Encrypted session string.secret(string): Encryption secret.- Returns the decrypted data table, or empty table on failure.
Request Parsing (Low-level)
For manual request handling without the router.
-
parse_request(conn_data, options): Parses raw connection data.- Returns a request object with
method,path,query,form,headers,cookies, etc.
- Returns a request object with
-
create_response(): Creates a response builder.- Returns a response object with
set_status(),json(),html(),redirect(), etc.
- Returns a response object with