Skip to main content

API Reference

Complete reference for all turn.* APIs available in Lua apps.


API Availability

The APIs documented here are available in different contexts:

ContextAvailable 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
When to use App Skills vs Code Skills
  • 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 within assets/.
  • 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

DEPRECATED

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:

  1. Creates all contact fields
  2. Installs all media assets and tracks their IDs
  3. Creates all journeys in two passes (create disabled, then link and enable)
  4. Creates all templates (skipped if same name+language already exists)
  5. Creates all WhatsApp flows (skipped if PUBLISHED, updated if DRAFT)

Parameters:

  • manifest (table): The manifest table (typically loaded from manifest.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 TypeManifest FieldFile Location
Media assetsasset_path: "images/logo.png"assets/images/logo.png
Journeysfile: "welcome.md"assets/journeys/welcome.md
Templatesfile: "welcome_message.json"assets/templates/welcome_message.json
Flowsfile: "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 from manifest.json)
  • options (table, optional): Configuration options
    • remove_contact_fields (boolean): If true, 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 from find().
    • 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 with type, name, and display keys.
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.

Available in Code Skills

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 with url, method, headers (table), body (string), and timeout (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 with name, notebook, and enabled.
  • update(journey_uuid, updates): Updates an existing Journey.
    • journey_uuid (string): The UUID of the journey to update.
    • updates (table): A table with name, notebook, or enabled.
  • delete(journey_def): Deletes a Journey by name.
    • journey_def (table): A table with the name of 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 true if the template exists, false otherwise.
  • create(template_def): Creates a new template.
    • template_def (table): A table with name, language, category, and components.
    • 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 with format (TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION) and text for TEXT format
  • BODY: Required message body with text containing the main content
  • FOOTER: Optional footer with text
  • BUTTONS: Optional buttons array with type (QUICK_REPLY, URL, PHONE_NUMBER, COPY_CODE, FLOW, VOICE_CALL)

Categories:

  • AUTHENTICATION: For one-time passwords and verification
  • MARKETING: For promotional content
  • UTILITY: 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 nil if 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 true if the flow exists, false otherwise.
  • create(flow_def): Creates a new flow.
    • flow_def (table): A table with name, json, and optional categories.
    • 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 updated
  • PUBLISHED: Flow is live and can be used in conversations
  • DEPRECATED: Flow has been marked for retirement
  • BLOCKED: Flow has been blocked by Meta
  • THROTTLED: 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 the app() 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.

Available in Code Skills

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.

Available in Code Skills

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 with pcall).
-- 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 %xx sequences 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 %20 and + 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.

Available in Code Skills

This API is available in UI-created Code Skills.

Security Notice

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. Returns true if valid, false otherwise. 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 (use sha256() 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 from aes_gcm_encrypt().
    • key (string): Must be exactly 32 bytes.
    • Returns: plaintext, nil on success, or nil, error_message on 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 locale
    • domain (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

  1. Locale bound via for_locale() (if using bound translator)
  2. options.locale - Explicit override per-call
  3. app.default_locale - From manifest.json
  4. "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 with data (string) and optional keys like filename, 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 with data (binary string), filename (string), and content_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 to assets/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 for t filters.
    • 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:

  1. locale: argument on the filter (highest priority)
  2. locale option passed to render()
  3. default_locale from app's manifest.json
  4. "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 route
  • router:post(path)(handler): Register POST route
  • router:put(path)(handler): Register PUT route
  • router:patch(path)(handler): Register PATCH route
  • router: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 path
  • params (table): Path parameters (e.g., :id becomes request.params.id)
  • query (table): Query string parameters
  • form (table): Form/body parameters
  • headers (table): Request headers as list of {name, value} tuples
  • cookies (table): Parsed cookies
  • session (table): Session data (when session_secret is configured)
  • csrf_token (string): CSRF token for form protection
  • is_htmx (boolean): true if request has HX-Request: true header

Response Object

Route handlers receive a response object with chainable methods:

  • set_status(code): Set HTTP status code
  • header(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-type
  • html(content): Send HTML response with proper content-type
  • text(content): Send plain text response with proper content-type
  • redirect(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): true if the request includes the HX-Request: true header
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 event
  • hx_trigger_after_settle(event, detail?): Trigger after DOM settles
  • hx_trigger_after_swap(event, detail?): Trigger after swap completes
  • hx_redirect(url): Client-side redirect (via HX-Redirect header)
  • hx_refresh(): Full page refresh
  • hx_push_url(url): Push URL to browser history
  • hx_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 element
  • hx_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 fully
  • response:hx_redirect(url) sets HX-Redirect header - 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 true if valid, false otherwise.
    • Checks for token in X-CSRF-Token header or _csrf_token form 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 to assets/liquid/.
    • variables (table, optional): Variables to pass to the template.
    • Automatically adds csrf_token and csrf_input to 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.
  • create_response(): Creates a response builder.

    • Returns a response object with set_status(), json(), html(), redirect(), etc.