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"
Header keys are always lowercase. HTTP headers are normalized to lowercase before being passed to Lua. Use 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.

  1. The first event with a given debounce_key schedules an execution for now + debounce.
  2. Subsequent events within the window replace the pending execution (new payload, same scheduled time).
  3. At the end of the window, the last received event executes.
  4. 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 forwardingtransform()route() → forward
  1. verify() and filter() always see the original request.
  2. Custom Forwarding applies static defaults.
  3. 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
Safe by default. A script crash never loses the HTTP result — the original execution status is preserved. Script errors are visible in the execution's 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)
  • nil and false are falsy; everything else (including 0 and "") is truthy
  • Equality is ==, not-equal is ~=
  • Logical operators: and, or, not
  • String length: #mystring, table length: #mytable
  • Pattern matching: string.match(str, pattern) or str:match(pattern)

For a complete reference, see the Lua 5.3 Reference Manual or the beginner-friendly Programming in Lua book.