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.

The original source request remains available as evidence while each connection branch can produce its own destination-ready request.

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:

TS
type TransformationEnvelope = {
  headers: Record<string, unknown>;
  body: unknown;
  query: Record<string, string | string[]>;
  path: string;
  method?: string;
};
FieldMeaning
headersForwardable request headers after FastHook removes internal control headers.
bodyThe parsed JSON body when possible, otherwise the original body value.
queryParsed query string values. Repeated parameters are arrays.
pathThe request path FastHook will use for the outbound delivery.
methodThe 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.

JS
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.

JS
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:

JS
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, or method fall back to the current request
  • provided headers replace the outbound headers after header-safe sanitization
  • provided query replaces the outbound query object after query-safe sanitization
  • provided body replaces the outbound body after JSON-safe sanitization
  • invalid or empty path and method fall back to the current values

Include existing values explicitly when you want to preserve them:

JS
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:

JS
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.

JSON
{
  "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:

Shell
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:

JSON
{
  "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.

Shell
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.

JSON
{
  "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
  }
}
Test a saved transformation against realistic input and inspect the result before enabling it on production traffic.

Attaching To A Connection

Transformations run from connection rules. A connection rule can reference a saved transformation by transformation_id.

JSON
{
  "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:

  1. deduplicate
  2. transform
  3. filter
  4. delay
  5. retry

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.

Enable Transform and open Editor to attach a tested transformation to this destination branch.

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.
  • const and let declarations. var is 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, and continue.
  • throw and synchronous try/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 replace and replaceAll.
  • Safe helpers from Object, Array, Math, Number, String, Boolean, JSON, Date, and URLSearchParams.
  • console.log, console.info, console.warn, and console.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.
  • async functions, 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:

Shell
npm run transformations:audit -- transformations.json

The 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_id
  • webhook_id, which is the connection id
  • transformation_id
  • original_event_data_id
  • transformed_event_data_id
  • original_event_data
  • transformed_event_data
  • log_level
  • logs
  • runtime
  • error_code for failed executions
  • stats_json with steps, loop iterations, collection operations, AST size, output bytes, and cache state
  • created_at and updated_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:

Shell
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:

Shell
curl "/v1/transformations/trs_.../executions/tex_..." \
  -H "Authorization: Bearer fhp_YOUR_PROJECT_API_KEY"
Transformation failures happen before destination delivery, so inspect the event and execution record before receiver logs.

Common Recipes

Add a destination API key

JS
addHandler("transform", (request, context) => ({
  ...request,
  headers: {
    ...request.headers,
    "authorization": `Bearer ${context.env.API_KEY}`
  }
}));

Rename body fields

JS
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

JS
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

JS
addHandler("transform", (request) => {
  const body = { ...request.body };
  delete body.card_number;
  delete body.raw_provider_payload;

  return {
    ...request,
    body
  };
});

Change path and method

JS
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.