Transformers are API-side Lua scripts that receive incoming HTTP requests and fan them out into multiple webhooks. This enables powerful use cases like:
- Event routing: Split a single incoming event to multiple destinations
- Payload transformation: Modify, filter, or enrich webhook payloads
- Protocol bridging: Transform between different webhook formats
- Fan-out patterns: One incoming request → N outgoing webhooks
Create a Lua file in your configured script directory:
-- /etc/howk/transformers/echo.lua
-- Simple echo transformer: forwards incoming payload to a single endpoint
local json = require("json")
local data = json.decode(incoming)
-- Create outgoing webhook
howk.post("https://httpbin.org/post", data)curl -X POST http://localhost:8080/incoming/echo \
-H "Content-Type: application/json" \
-d '{"message": "hello", "user": "alice"}'Response:
{
"webhooks": [
{"id": "wh_01JHX...", "endpoint": "https://httpbin.org/post"}
],
"count": 1
}Enable transformers in your configuration:
# config.yaml
transformer:
enabled: true
script_dirs:
- "/etc/howk/transformers"
passwd_dirs: [] # Optional: separate dir for .passwd files
timeout: 500ms # Script execution timeout
memory_limit_mb: 50 # Lua memory limitOr via environment variables:
export HOWK_TRANSFORMER_ENABLED=true
export HOWK_TRANSFORMER_SCRIPT_DIRS=/etc/howk/transformers
export HOWK_TRANSFORMER_TIMEOUT=500ms
export HOWK_TRANSFORMER_MEMORY_LIMIT_MB=50When deploying with Helm, configure transformers in values.yaml:
config:
transformer:
enabled: true
scriptDirs:
- "/etc/howk/transformers"
timeout: "500ms"
memoryLimitMb: 50
# Define transformer scripts inline
transformerScripts:
stripe-router: |
local json = require("json")
local ok, event = pcall(json.decode, incoming)
if not ok then return end
if event.type == "charge.succeeded" then
howk.post("https://billing.internal/webhook", event)
end
howk.post("https://analytics.internal/track", event)
# Optional: JSON configs per script
transformerConfigs:
stripe-router: |
{"allowed_domains": ["billing.internal", "analytics.internal"]}
# Optional: Basic Auth
transformerAuth:
stripe-router: |
admin:$2b$12$xxxxxxxx...The Helm chart creates ConfigMaps for scripts/configs/auth and mounts them into the API pods.
Each transformer consists of 1-3 files:
/etc/howk/transformers/
├── my-transformer.lua # Required: Lua script
├── my-transformer.json # Optional: Configuration
└── my-transformer.passwd # Optional: Basic Auth credentials
Script names must match the pattern: ^[a-z0-9][a-z0-9_-]*$
- Start with alphanumeric
- Contain only: lowercase letters, numbers, underscores, hyphens
- Examples:
stripe-webhook,billing_v2,alert-handler
These are the only values injected as globals. Everything else (including json, base64, kv, http, crypto) must be loaded with require(...).
| Global | Type | Description |
|---|---|---|
incoming |
string | Raw request body (bytes as string) |
headers |
table | HTTP request headers |
config |
table | Contents of [script].json (empty if no file) |
howk |
table | Fan-out API — see below |
log |
table | Structured logging — see Built-in Modules |
Creates and queues a webhook for delivery.
Parameters:
endpoint(string, required): Target URL (must matchallowed_domainsif configured)payload(table, required): Data to send (automatically JSON-encoded)options(table, optional):headers- Additional HTTP headers (table)signing_secret- HMAC-SHA256 secret for signatureconfig_id- Override default config_id (default: script name)max_attempts- Max retry attempts (default: 20)
Returns: Webhook ID string (e.g., wh_01JHX...)
Example:
local id = howk.post("https://api.example.com/webhook", {
event = "user.created",
user_id = "123"
}, {
headers = {["X-Custom"] = "value"},
signing_secret = "my-secret",
max_attempts = 10
})
log.info("Created webhook: " .. id)Same modules available as worker-side scripts.
Important:
json,base64,kv,http, andcryptoare preload modules — you must callrequire("...")before using them. Onlylogis available as a global (norequireneeded).local json = require("json") local base64 = require("base64") local kv = require("kv") local http = require("http") local crypto = require("crypto")Forgetting the
requireresults in errors likeattempt to index a non-table object(nil) with key 'decode'.
| Module | Kind | Functions |
|---|---|---|
json |
preload (require("json")) |
json.encode(table), json.decode(string) |
base64 |
preload (require("base64")) |
base64.encode(str), base64.decode(str) |
kv |
preload (require("kv")) |
kv.get(key), kv.set(key, value [, ttl]), kv.del(key) |
http |
preload (require("http")) |
http.get(url [, headers [, options]]) |
crypto |
preload (require("crypto")) |
crypto.decrypt_credential(key_suffix, sym_key, data) |
log |
global | log.info(msg [, fields]), log.warn(), log.error(), log.debug() |
The /incoming/:script_name endpoint accepts any content type. The raw body is passed to your script as the incoming string.
local json = require("json")
-- Handle JSON with error checking
local ok, data = pcall(json.decode, incoming)
if not ok then
log.error("Invalid JSON received", {error = data})
-- Script ends, no webhooks created
return
end
-- Process the data
howk.post("https://api.example.com/webhook", data)-- Parse form data manually
local function parse_formdata(body)
local result = {}
for pair in string.gmatch(body, "([^&]+)") do
local key, value = string.match(pair, "([^=]+)=([^=]*)")
if key then
-- URL decode (basic)
value = string.gsub(value, "%%(%x%x)", function(h)
return string.char(tonumber(h, 16))
end)
result[key] = value
end
end
return result
end
local data = parse_formdata(incoming)
howk.post("https://api.example.com/webhook", data)-- For XML, you might extract specific fields with patterns
-- or use the raw body
local webhook_id = howk.post("https://api.example.com/xml-webhook", {
raw_xml = incoming,
content_type = headers["Content-Type"] or "application/xml"
})-- Simple text processing
local lines = {}
for line in string.gmatch(incoming, "[^\r\n]+") do
table.insert(lines, line)
end
howk.post("https://api.example.com/log", {
line_count = #lines,
first_line = lines[1]
})The JSON file controls script behavior:
{
"allowed_domains": ["api.example.com", "hooks.slack.com"],
"webhook_timeout": 30,
"custom_setting": "value"
}Critical security feature! If specified, howk.post() will reject endpoints not matching:
{
"allowed_domains": ["*.internal.company.com", "api.stripe.com"]
}- Exact match:
"api.example.com"matcheshttps://api.example.com/... - Wildcard subdomains:
"*.example.com"matcheshttps://foo.example.com/... - Blocks all other domains - requests fail with error
If allowed_domains is not specified (or no JSON file), all domains are allowed.
-- stripe-ingest.json: {"stripe_version": "2024-01", "allowed_domains": [...]}
local version = config.stripe_version or "default"
log.info("Processing with Stripe version", {version = version})Some endpoints embed secrets directly in the URL (Google Chat, Slack incoming webhooks, Discord, internal services keyed by query string). Storing those URLs on Kafka would persist the secret — and Kafka is the audit log.
HOWK solves this with late-bound delivery overrides: declare the secret-bearing query params and/or headers as unresolved ${VAR_NAME} templates in the transformer's .json. The templates travel on the Webhook record (templates are not secrets), and the worker resolves them against its own process environment at HTTP-send time. The persisted webhook, retry data, and DeliveryResult only ever see the bare endpoint.
Two keys in [script].json trigger this behavior. Both are stripped from config.* in Lua — they are consumed by the delivery layer, not the script:
| Key | Effect |
|---|---|
_delivery_query_params |
Map of name → "${ENV_VAR}". Merged into the URL query string at send time (existing query params on the endpoint are preserved; matching keys are overwritten). |
_delivery_headers |
Map of name → "${ENV_VAR}". Merged into outbound HTTP headers at send time (override webhook headers on key collision). |
/etc/howk/transformers/gondola-google-chat.lua:
local json = require("json")
local data = json.decode(incoming)
howk.post(
"https://chat.googleapis.com/v1/spaces/AAQAIYOJwdQ/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD",
{ text = data.text or "(no text)" }
)/etc/howk/transformers/gondola-google-chat.json:
{
"allowed_domains": ["chat.googleapis.com"],
"_delivery_query_params": {
"key": "${GONDOLA_GOOGLE_CHAT_KEY}",
"token": "${GONDOLA_GOOGLE_CHAT_TOKEN}"
}
}Worker environment:
export GONDOLA_GOOGLE_CHAT_KEY=AIza...
export GONDOLA_GOOGLE_CHAT_TOKEN=abc...What gets stored on Kafka (Webhook.endpoint):
https://chat.googleapis.com/v1/spaces/AAQAIYOJwdQ/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD
What the worker actually sends:
https://chat.googleapis.com/v1/spaces/AAQAIYOJwdQ/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD&key=AIza...&token=abc...
- Templates only on Kafka. The Webhook record carries
${VAR}strings under_delivery_query_params/_delivery_headers. Resolved values exist only inside the transient struct passed to the HTTP client. - Existing query params preserved.
messageReplyOption=...survives the merge; only matching keys are overwritten. - Empty resolutions are dropped. A missing env var produces no
?key=(rather than?key=), preventing accidental empty-value sends. - Headers override webhook headers on key collision (e.g.
Authorization). - Retries and reconciliation re-apply overrides. The templates ride along on
WebhookStateSnapshot, so a worker rebuilding state fromhowk.stateor replaying a retry produces the same outbound URL. - Opt-in. Webhooks with no overrides (no transformer, or a transformer whose
.jsonlacks the reserved keys) are unaffected.
The worker checks two sources, in order:
- Templates attached to the Webhook itself — set by the API-side transformer from its companion
.json. This is the typical disk-mount path (the example above). - Worker-side script loader — overrides declared in
script_configfor a published worker script (PUT /config/:config_id/script, topichowk.scripts). Useful when the same secret-bearing endpoint is shared by multiple namespaced webhook configs that resolve to the same script via namespace fallback.
- Templates are not secrets; treat env var names as public.
- Anyone with write access to the transformer's
.jsoncan reference any env var visible to the worker process. Restrict who can deploy transformer configs. - Pair this with
allowed_domainsso a script can't be edited to exfiltrate secrets to an attacker-controlled host. - Use
extraEnvFrom(Helm) or your secret manager of choice to inject the env vars into the worker pods only — the API does not need them.
Protect scripts with HTTP Basic Auth:
Generate hash:
# Using htpasswd (Apache)
htpasswd -bnBC 12 "" "my-secret-password" | tr -d ':\n' | sed 's/$2y/$2b/'
# Output: $2b$12$xxxxxxxx...Create .passwd file:
# /etc/howk/transformers/stripe-ingest.passwd
admin:$2b$12$xxxxxxxx...
webhook:$2b$12$yyyyyyyy...
# NOT recommended for production!
devuser:devpassword
curl -X POST http://localhost:8080/incoming/stripe-ingest \
-u admin:my-secret-password \
-H "Content-Type: application/json" \
-d '{"type": "charge.succeeded"}'Always use pcall for operations that might fail:
local json = require("json")
local http = require("http")
-- Safe JSON parsing
local ok, data = pcall(json.decode, incoming)
if not ok then
log.error("JSON parse failed", {error = tostring(data)})
-- Return empty - no webhooks created
return
end
-- Safe HTTP calls
local ok, response, err = pcall(http.get, "https://api.example.com/data")
if not ok or err then
log.error("HTTP call failed", {error = err or tostring(response)})
return
end| Status | Meaning |
|---|---|
| 200 | Success - webhooks created (count may be 0) |
| 400 | Bad request (body too large) |
| 401 | Unauthorized (invalid/missing credentials) |
| 404 | Script not found or transformer feature disabled |
| 413 | Request entity too large |
| 500 | Script execution error |
A script may legitimately create 0 webhooks:
-- Event filter transformer
local json = require("json")
local data = json.decode(incoming)
if data.event_type ~= "critical_alert" then
log.info("Ignoring non-critical event", {type = data.event_type})
-- No howk.post() called - returns {"webhooks": [], "count": 0}
return
end
howk.post("https://pagerduty.com/integration", data)Response:
{"webhooks": [], "count": 0}For copy-paste ready examples covering common use cases, see transformers-examples.md.
Available examples:
- Basic Echo - Simple forwarding
- Event Filtering - Selective processing
- Webhook Routing - Route to different endpoints
- Payload Transformation - Modify/enrich data
- Error Handling Patterns - Safe JSON parsing, validation
- Working with Headers - Signature validation
- KV Store Usage - Deduplication, rate limiting, state
- HTTP Calls - External API integration
- Multi-Destination Fan-Out - One-to-many delivery
-- Simple event router
local json = require("json")
local ok, data = pcall(json.decode, incoming)
if not ok then
log.error("Invalid JSON")
return
end
-- Route based on event type
if data.type == "user.created" then
howk.post("https://crm.internal/webhook", data)
elseif data.type == "order.placed" then
howk.post("https://fulfillment.internal/webhook", data)
end
-- Always send to analytics
howk.post("https://analytics.internal/track", data)When using the Helm chart, you have three options for reloading:
configmapReload:
enabled: true
method: annotationWhen you update transformerScripts in values and run helm upgrade, pods automatically restart with new scripts.
configmapReload:
enabled: true
method: signal
sleepTime: 15Uses a sidecar container that watches ConfigMaps and sends SIGHUP to reload scripts without pod restart.
# Send SIGHUP to all API pods
kubectl exec deploy/howk-api -- kill -HUP 1
# Or restart deployment
kubectl rollout restart deployment/howk-api# Send SIGHUP to the API process
kill -HUP $(pgrep howk-api)
# Or if running in Docker
docker kill -s HUP <container>Logs will show:
INFO Received SIGHUP, reloading transformer scripts
INFO Loaded transformer scripts count=5
log.debug("Received request", {
content_type = headers["Content-Type"],
body_size = #incoming,
user_agent = headers["User-Agent"]
})# Set up test directory
mkdir -p /tmp/howk-transformers
echo 'print("Hello from Lua")' > /tmp/howk-transformers/test.lua
# Run API with test scripts
HOWK_TRANSFORMER_ENABLED=true \
HOWK_TRANSFORMER_SCRIPT_DIRS=/tmp/howk-transformers \
HOWK_LOG_LEVEL=debug \
go run ./cmd/api# Check your JSON syntax
jq . /etc/howk/transformers/my-script.jsonThe response shows exactly which webhooks were created:
curl -s -X POST http://localhost:8080/incoming/my-script \
-d '{"test": true}' | jq .- Always use
allowed_domainsin production to prevent SSRF attacks - Use bcrypt passwords in
.passwdfiles - Validate all input with
pcallbefore processing - Don't log sensitive data (passwords, tokens, PII)
- Set memory limits to prevent resource exhaustion
- Use HTTPS endpoints only in production
- Script timeout: Configurable, default 500ms
- Memory limit: Per-script, default 50MB
- Max request body: Same as API config (default 1MB)
- No filesystem access from Lua (
iomodule disabled) - No OS commands from Lua (
osmodule disabled)