Transformations
Transformations let you modify a webhook request before FastHook delivers it to a destination.
Use them when the source payload and the destination contract do not line up: rename fields, add headers, normalize nested objects, remove noisy data, change the outbound path, or shape a provider-specific event into the format your internal service expects.
In FastHook, transformations are saved FastHook Transform DSL v1 snippets. The DSL uses familiar JavaScript-like syntax, but FastHook parses the code into an AST and evaluates only documented operations. It does not use eval, Function, or a browser/Node.js JavaScript runtime.
A connection can reference one or more saved transformations through transform rules. During delivery, FastHook loads the transformation, runs it against the request envelope, stores the transformed payload, and records an execution audit entry.
When To Use A Transformation
Use a transformation when the webhook should still be delivered, but the outbound request needs a different shape.
- Add authentication or routing headers expected by the destination.
- Rename fields from provider terminology to internal terminology.
- Flatten deeply nested provider payloads into a compact event body.
- Remove fields that downstream systems should not receive.
- Convert query parameters, path, or method for a destination endpoint.
- Add derived values such as totals, flags, or normalized identifiers.
Use a Filter instead when the event should not be delivered at all. Filters decide whether an event continues. Transformations change the event that continues.
Request Envelope
A transformation receives a request envelope with five fields:
type TransformationEnvelope = {
headers: Record<string, unknown>;
body: unknown;
query: Record<string, string | string[]>;
path: string;
method?: string;
};| Field | Meaning |
|---|---|
headers | Forwardable request headers after FastHook removes internal control headers. |
body | The parsed JSON body when possible, otherwise the original body value. |
query | Parsed query string values. Repeated parameters are arrays. |
path | The request path FastHook will use for the outbound delivery. |
method | The outbound HTTP method. It is preserved unless you replace it. |
Basic Script Shape
Register a transform handler with addHandler. The handler receives the current request envelope and a context object. Manual runs in the dashboard and production deliveries use this exact same DSL runtime.
addHandler("transform", (request, context) => {
return {
...request,
headers: {
...request.headers,
"x-api-key": context.env.API_KEY
},
body: {
...request.body,
transformed: true
}
};
});The same environment values are also exposed as process.env.
addHandler("transform", (request) => ({
...request,
headers: {
...request.headers,
"x-tenant": process.env.TENANT_ID
}
}));If several transform handlers are registered inside one script, FastHook uses the last registered handler.
Return Values
The safest pattern is to return the full request envelope:
addHandler("transform", (request) => ({
headers: request.headers,
body: {
event: request.body.type,
payload: request.body.data
},
query: request.query,
path: request.path,
method: request.method
}));FastHook normalizes the returned value before delivery:
- missing
headers,query,path, ormethodfall back to the current request - provided
headersreplace the outbound headers after header-safe sanitization - provided
queryreplaces the outbound query object after query-safe sanitization - provided
bodyreplaces the outbound body after JSON-safe sanitization - invalid or empty
pathandmethodfall back to the current values
Include existing values explicitly when you want to preserve them:
addHandler("transform", (request) => ({
...request,
body: {
...request.body,
normalized: true,
original_type: request.body.type
}
}));Mutating The Body
The DSL runtime gives the handler a cloned request. You can mutate the clone and return the request:
addHandler("transform", (request) => {
request.body.received_by = "fasthook";
request.path = "/normalized-webhook";
return request;
});Return a new envelope when changing headers or query. That keeps the script easy to read, easy to test, and predictable during replay.
Environment Values
Each transformation has optional environment JSON. FastHook stores it as env_json and exposes string values to the script.
{
"API_KEY": "fh_live_...",
"TENANT_ID": "acme"
}Use environment values for secrets, destination-specific constants, or reusable configuration. Do not hardcode secrets in the script body.
Only string values are exposed. Non-string values in environment JSON are ignored by the runtime.
Validate Before Saving
The dashboard Validate action parses and checks the entire program without running it. The same check is available through PUT /v1/transformations/validate:
curl -X PUT "/v1/transformations/validate" \
-H "Authorization: Bearer fhp_YOUR_PROJECT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "addHandler(\"transform\", (request) => ({ ...request, body: { ok: true } }));"
}'A valid response identifies the runtime, AST size, cache state, and enforced limits. Invalid code returns HTTP 400 with a stable diagnostic code, message, line, column, and source snippet:
{
"error": "fetch is not defined",
"diagnostic": {
"runtime": "fasthook-dsl-v1",
"code": "unknown_identifier",
"message": "fetch is not defined",
"line": 2,
"column": 3,
"snippet": " fetch(request.path);"
}
}Save and run endpoints use the same structured diagnostic format. Use diagnostic.code for automation and the human-readable fields for the editor.
Testing A Transformation
Use the dashboard editor or call PUT /v1/transformations/run with inline code, a saved transformation_id, or both.
curl -X PUT "/v1/transformations/run" \
-H "Authorization: Bearer fhp_YOUR_PROJECT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "addHandler(\"transform\", (request) => ({ ...request, body: { ...request.body, normalized: true } }));",
"env": {},
"request": {
"method": "POST",
"path": "/stripe/webhook",
"query": "mode=live",
"headers": {
"content-type": "application/json"
},
"body": {
"type": "invoice.paid"
}
}
}'The run endpoint returns the transformed request, captured console lines, the resulting log level, DSL runtime/version, execution counters, and timing metadata.
{
"runtime": "fasthook-dsl-v1",
"stats": {
"steps": 37,
"loopIterations": 0,
"collectionOperations": 6,
"astNodeCount": 31,
"outputBytes": 148,
"cacheHit": true
},
"log_level": "info",
"console": [],
"request": {
"headers": {
"content-type": "application/json",
"content-length": "23"
},
"body": {
"type": "invoice.paid",
"normalized": true
},
"query": {
"mode": "live"
},
"path": "/stripe/webhook",
"method": "POST"
},
"transformation_run_time": {
"started_at": "2026-05-16T13:00:00.000Z",
"finished_at": "2026-05-16T13:00:00.004Z",
"duration_ms": 4
}
}Attaching To A Connection
Transformations run from connection rules. A connection rule can reference a saved transformation by transformation_id.
{
"type": "transform",
"transformation_id": "trs_..."
}The legacy rule type transformation is normalized to transform.
When you create or update a connection, FastHook normalizes rules in this order:
deduplicatetransformfilterdelayretry
That means transformation rules run before filter rules in the normalized connection pipeline. If your filter should inspect the original source payload, keep that in mind and put the filtering condition in a place that matches the actual normalized rule behavior.
Runtime Guidance
FastHook transformations are designed for request shaping, not long-running business workflows. Keep scripts focused, deterministic, and limited to the request data and environment values they need.
Supported language features
- String, number, boolean, null, array, and object literals.
constandletdeclarations.varis rejected so block scoping is never ambiguous.- Object and array spread, computed properties, destructuring, optional chaining, template literals, and ternaries.
- Arithmetic, comparison, logical, nullish, compound assignment, and increment/decrement operators.
if/else,switch,for,for...in,for...of,while,do...while,break, andcontinue.throwand synchronoustry/catch/finally. Runtime resource and security limit errors cannot be caught by a script.- Local functions and inline callbacks.
- Array helpers:
map,flatMap,filter,reduce,forEach,some,every,find,findIndex,sort,slice,includes,join,concat, and basic mutations. - String helpers including callback-based
replaceandreplaceAll. - Safe helpers from
Object,Array,Math,Number,String,Boolean,JSON,Date, andURLSearchParams. console.log,console.info,console.warn, andconsole.error.
Not supported
- Network or I/O APIs such as
fetch, databases, storage bindings, or filesystem access. eval,Function, dynamic imports, modules, classes, generators, WebAssembly, or regular expressions.asyncfunctions,await, promises, timers, browser globals, or Node.js globals.- Prototype access through
constructor,prototype, or__proto__.
FastHook validates the complete AST before execution. Unsupported syntax fails with a DSL error and a line/column. Execution happens against a clone, so a failing transformation never delivers a partially changed request. Loops, calls, AST size, and total execution steps have hard limits.
Deterministic time
For a live delivery, context.timestamp is the request ingestion timestamp and context.now is the same instant in Unix milliseconds. Date.now(), new Date(), and Date() use that instant, so replaying the same captured request produces the same default time. Passing an explicit value to new Date(value) still uses that value.
For an inline run, send timestamp in the request body when exact replay time matters. If it is omitted, FastHook captures one timestamp at the beginning of the run and uses it consistently for the whole script.
Runtime limits
Each execution is bounded independently:
| Resource | Limit | | --- | ---: | | Source code | 100,000 characters | | Parsed AST | 25,000 nodes | | Execution | 100,000 steps | | Loops | 5,000 iterations | | Array, object, string, and spread work | 50,000 collection operations | | Function calls | 32 levels deep | | Console | 500 lines, 10,000 characters per line | | Serialized output | 16 MiB |
Limit failures use stable codes such as code_limit, ast_limit, step_limit, loop_limit, collection_limit, call_depth, and output_limit. They are atomic and cannot be suppressed with try/catch.
FastHook keeps a small content-addressed cache of validated ASTs. It contains code only, never request or environment data, and does not change script behavior. The cacheHit statistic shows whether it was used.
Runtime Version And Migration
New and updated records are saved as fasthook-dsl-v1. Existing records from the earlier interpreter are marked javascript-legacy; the marker makes compatibility visible instead of silently changing semantics.
Audit all saved transformations with GET /v1/transformations/compatibility. Each record is reported as current, compatible, or incompatible, and incompatible records include the same structured diagnostic used by validation.
POST /v1/transformations/compatibility promotes only legacy records that pass the complete DSL v1 validator. It never rewrites code and leaves incompatible records untouched for manual repair. The dashboard exposes the same flow through Audit legacy and Promote compatible.
For an offline export, run:
npm run transformations:audit -- transformations.jsonThe file can be a JSON array or an object with a models array. The command exits non-zero when any transformation is incompatible.
Execution History
Every successful transformation in a live connection produces a transformed event data record and queues an execution audit write.
The execution record stores:
event_idwebhook_id, which is the connection idtransformation_idoriginal_event_data_idtransformed_event_data_idoriginal_event_datatransformed_event_datalog_levellogsruntimeerror_codefor failed executionsstats_jsonwith steps, loop iterations, collection operations, AST size, output bytes, and cache statecreated_atandupdated_at
FastHook persists error-level console logs with the execution. Informational logs are returned by manual test runs, but live execution history only stores logs when the transformation produced an error log level.
List recent executions:
curl "/v1/transformations/trs_.../executions?limit=20&dir=desc" \
-H "Authorization: Bearer fhp_YOUR_PROJECT_API_KEY"Retrieve one execution with original and transformed payloads:
curl "/v1/transformations/trs_.../executions/tex_..." \
-H "Authorization: Bearer fhp_YOUR_PROJECT_API_KEY"Common Recipes
Add a destination API key
addHandler("transform", (request, context) => ({
...request,
headers: {
...request.headers,
"authorization": `Bearer ${context.env.API_KEY}`
}
}));Rename body fields
addHandler("transform", (request) => ({
...request,
body: {
event_type: request.body.type,
object_id: request.body.data?.object?.id,
received_at: new Date().toISOString()
}
}));new Date() uses the captured request timestamp, so this recipe remains deterministic across replay. Use context.timestamp directly when the destination expects the original ISO string.
Flatten a provider payload
addHandler("transform", (request) => {
const invoice = request.body.data.object;
return {
...request,
body: {
id: invoice.id,
customer_id: invoice.customer,
total: invoice.amount_paid,
currency: invoice.currency,
status: invoice.status
}
};
});Remove sensitive fields
addHandler("transform", (request) => {
const body = { ...request.body };
delete body.card_number;
delete body.raw_provider_payload;
return {
...request,
body
};
});Change path and method
addHandler("transform", (request) => ({
...request,
path: "/internal/webhooks/billing",
method: "POST"
}));Design Tips
- Keep each transformation focused on request shaping.
- Prefer returning a new request object over mutating deeply nested state.
- Preserve fields that downstream systems already rely on.
- Use lowercase header names for consistency.
- Keep secrets in environment JSON.
- Test with real provider samples before attaching to production connections.
- Check execution history when a destination receives a surprising payload.
- Use transformations for operational compatibility, not long-running business decisions.
Common Questions
Do transformations run before delivery?
Yes. A transformation runs in the connection worker before FastHook queues the outbound destination delivery.
Can one connection run multiple transformations?
Yes. A connection can contain multiple transform rules. They run in normalized rule order, and each transformation receives the request produced by the previous transformation.
What happens when a transformation is missing?
The connection event is marked failed with a transformation_not_found failure message, and the destination delivery is skipped.
What happens when transformation code throws?
The connection worker treats it as transformation_execution_failed, records the failure on the event flow, and does not send a transformed payload to the destination. Validation and runtime failures are atomic: changes made before the error are discarded.
Are transformation executions stored?
Yes. FastHook stores original and transformed event data ids, the original and transformed envelopes, runtime/version, structured failure code, execution statistics, timestamps, and error-level logs for live execution history.