Lua Scripting Pro
Write Lua scripts to process inbound webhooks and outbound task responses. Same sandbox, same builtins — two surfaces.
Inbound Endpoints
Four optional functions run in order on every inbound event. Define only the ones you need — undefined functions are skipped.
Pipeline Overview
receive → verify() → filter() → transform() → route() → forward
| Function | Arguments | Purpose |
|---|---|---|
verify(event, secrets) |
event table, secrets table | Validate signatures or auth. Return false to reject (403). Use return false, 422 to choose a different status code. |
filter(event) |
event table | Drop unwanted events. Return false to drop (200). Use return false, 204 to choose a different status code. |
transform(event) |
event table | Modify the forwarded request. Return a table with body, headers, or method. Can also return debounce_key and debounce to coalesce rapid events. |
route(event, urls) |
event table, URL list | Choose which forward URLs receive this event. Return a filtered list — only URLs already in your forward list are allowed. |
The Event Table
Every function receives an event table with the original request data:
-- Available in every function event.method -- "POST", "PUT", etc. event.headers -- {["content-type"] = "application/json", ...} event.body -- raw request body string event.data -- auto-parsed JSON body as a table (empty table if not JSON) event.source_ip -- "203.0.113.42"
event.headers["x-stripe-signature"], not
event.headers["X-Stripe-Signature"].
verify(event, secrets)
Validate that the request is authentic. Typically used to check webhook signatures.
| Return | Effect |
|---|---|
true (or any truthy value) |
Event accepted, continues to next step |
false |
Event rejected, responds 403 |
false, status_code |
Event rejected with custom status code (100–599) |
Example: Stripe Signature Verification
function verify(event, secrets) local sig = event.headers["stripe-signature"] if not sig then return false end local ts, hash = sig:match("t=(%d+),v1=(%S+)") if not ts or not hash then return false end local expected = hmac_sha256(secrets.signing_key, ts .. "." .. event.body) return expected == hash end
filter(event)
Drop events you don't care about. Return false to silently drop (200), or false, status_code for a custom status.
Example: Filter by Event Type
function filter(event) if event.data.type == "checkout.session.completed" then return true end if event.data.type == "invoice.paid" then return true end return false end
transform(event)
Modify what gets forwarded. Return a table with body, headers, or method. You can also return debounce_key and debounce to coalesce rapid events (see below). Each key is optional — omitted fields keep the original value.
Example: Simplify Payload
function transform(event) local slim = { type = event.data.type, id = event.data.id, } return { body = json.encode(slim), headers = {["content-type"] = "application/json"} } end
Debounce via transform()
Return debounce_key and debounce from transform() to coalesce rapid-fire events into a single execution.
Useful when services send duplicate or high-frequency events (e.g. Stripe retries, CMS saves on every keystroke).
| Field | Type | Description |
|---|---|---|
debounce_key |
string | Groups events for deduplication (e.g. an order ID, user ID) |
debounce |
string | Window duration (e.g. "30s", "5m") |
How it works:
Debounce uses a fixed window. The first event schedules an execution for now + debounce.
Subsequent events within the window replace the pending execution but keep the same scheduled time — the window does not slide.
- The first event with a given
debounce_keyschedules an execution fornow + debounce. - Subsequent events within the window replace the pending execution (new payload, same scheduled time).
- At the end of the window, the last received event executes.
- After the window passes, the next event starts a new cycle.
This gives you at most one execution per window. Events cannot push the execution time further into the future.
Both fields are required for debouncing to activate. A queue is automatically assigned when debouncing.
Example: Debounce Stripe Events
function transform(event) return { debounce_key = event.data.data.object.id, debounce = "30s" } end
Example: Debounce CMS Saves
function transform(event) return { debounce_key = event.data.entry_id, debounce = "5m", body = json.encode({entry_id = event.data.entry_id}) } end
route(event, urls)
Choose which forward URLs receive this event. Only URLs in the endpoint's forward list are allowed — any others are silently removed.
Example: Route by Event Type
function route(event, urls) if event.data.type == "invoice.paid" then return {"https://billing.myapp.com/webhooks/stripe"} end return urls end
Secrets
Store signing keys and API tokens as encrypted secrets on the endpoint. Secrets are passed to
verify() as the second argument.
They are encrypted at rest and never returned in API responses.
Error Handling (Endpoints)
Scripts are sandboxed and errors never block event delivery:
| Function | On Error |
|---|---|
verify() |
Event accepted — error logged as warning |
filter() |
Event accepted — error logged as warning |
transform() |
Event forwarded unchanged |
route() |
Event forwarded to all URLs |
Interaction with Custom Forwarding
When both Lua scripting and Custom Forwarding are configured:
receive → verify(original) → filter(original) → custom forwarding → transform() → route() → forward
verify()andfilter()always see the original request.- Custom Forwarding applies static defaults.
transform()runs last — returned values override Custom Forwarding.
Task Scripts
Add a script field to any task. After the HTTP request completes, your on_response function can inspect the result, override success/failure, request retries, or spawn follow-up tasks.
Quick Example
curl -X POST https://runlater.eu/api/v1/tasks \ -H "Authorization: Bearer pk_xxx.sk_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/process", "method": "POST", "body": "{\"action\": \"sync\"}", "script": "function on_response(response)\n if response.data.error then\n return {status = \"failed\", error = response.data.error}\n end\nend" }'
The Response Object
Your on_response function receives a response table:
| Field | Type | Description |
|---|---|---|
response.status_code |
number | HTTP status code (nil for timeouts/connection errors) |
response.body |
string | Response body (truncated to 256KB) |
response.data |
table | Auto-parsed JSON body (empty table if not JSON) |
response.headers |
table | Response headers |
response.duration_ms |
number | Request duration in milliseconds |
response.status |
string | "success", "failed", or "timeout" |
Return Values
| Return | Effect |
|---|---|
nil or no return |
No override — original status preserved |
{status = "failed"} |
Override to failed |
{status = "failed", error = "msg"} |
Override to failed with custom error message |
{status = "success"} |
Override to success |
{retry_after = N} |
Request retry after N seconds (one-time tasks only) |
create_task()
Spawn follow-up tasks from within a script. The new task is created as a one-time task and executed after the current script finishes.
| Parameter | Type | Description |
|---|---|---|
url |
string | Target URL (required) |
method |
string | HTTP method (default: "POST") |
headers |
table | Request headers |
body |
string | Request body |
script |
string | Lua script for the spawned task |
delay |
string | Delay before execution (e.g. "30s", "5m", "2h", "1d") |
queue |
string | Named queue for serialized execution |
callback_url |
string | Callback URL on completion |
retry_attempts |
number | Number of retries (default: 5) |
timeout_ms |
number | Request timeout in ms (default: 30000) |
Returns the task ID (string) on success, or nil if limits are reached.
Examples
Custom Success/Failure
Override success when the API returns 200 with an error in the body:
function on_response(response) if response.status_code == 200 and response.data.error then return {status = "failed", error = response.data.error} end end
Conditional Retry
Use the server's Retry-After hint on 429 responses:
function on_response(response) if response.status_code == 429 then return {status = "failed", retry_after = 120} end end
Fan-out
Fetch a list and create a task per item:
function on_response(response) local items = response.data.items if not items then return end for i, item in ipairs(items) do create_task({ url = "https://api.example.com/process/" .. item.id, method = "POST", body = json.encode(item) }) end end
Sequential Chain (A → B)
After task A completes, spawn task B:
function on_response(response) if response.status == "success" then create_task({ url = "https://api.example.com/step-2", body = response.body }) end end
Delayed Follow-up
Schedule a follow-up task for later:
function on_response(response) create_task({ url = "https://api.example.com/verify", delay = "5m" }) end
Safety
| Limit | Value |
|---|---|
| Max chain depth | 10 levels (A spawns B spawns C…) |
| Max spawned tasks per execution | 100 |
| Script timeout | 100ms |
script_logs.
General Reference
Built-in Functions
| Function | Description |
|---|---|
json.decode(str) |
Parse a JSON string into a Lua table. Returns nil on invalid JSON. |
json.encode(table) |
Serialize a Lua table to a JSON string |
hmac_sha256(key, data) |
Compute HMAC-SHA256, returns lowercase hex string |
hmac_sha1(key, data) |
Compute HMAC-SHA1, returns lowercase hex string |
base64_encode(data) |
Encode to Base64 |
base64_decode(data) |
Decode from Base64. Returns nil on invalid input. |
hex_encode(data) |
Encode to lowercase hex |
log(msg) |
Write to script logs (visible in event/execution details) |
create_task(params) |
Spawn a follow-up task. Task scripts only. |
Standard Lua functions are available: string.*,
table.*,
math.*,
type(),
tostring(),
pairs(),
ipairs(), and pattern matching via string.match().
Limits
| Limit | Value |
|---|---|
| Execution timeout | 500ms per endpoint event, 100ms per task execution |
| Max chain depth (task scripts) | 10 levels |
| Max spawned tasks per execution | 100 |
Disabled Modules
For security, the following Lua modules are disabled:
os,
io,
debug,
coroutine,
loadfile,
dofile, and
require.
Lua Language Reference
Runlater uses Lua 5.3 syntax. If you're new to Lua, here are the essentials:
- Variables are declared with
local(always use local) - Tables are the only data structure:
{key = "value"}for objects,{"a", "b"}for arrays - String concatenation uses
..(two dots) nilandfalseare falsy; everything else (including0and"") is truthy- Equality is
==, not-equal is~= - Logical operators:
and,or,not - String length:
#mystring, table length:#mytable - Pattern matching:
string.match(str, pattern)orstr:match(pattern)
For a complete reference, see the Lua 5.3 Reference Manual or the beginner-friendly Programming in Lua book.