Skip to content
Back to site

Transformations

A transformation turns the event payload into the body a receiver expects. Only the delivery body changes; the stored event does not.

A transformation’s kind decides how its text is read.

kind What it is Reach for it when
TEMPLATE A JSON document whose string values can contain ${$.jsonpath} expressions. The default. The receiver wants the same fields under different names, or a subset. Declarative: no loops, no surprises.
JAVASCRIPT One function handler(webhook) that returns the body, run in a sandbox. The shape has to be computed: an array to walk, a field that depends on a value, a date to format, a delivery to drop.

Omitting kind means TEMPLATE. Transformations created before kind existed are templates and stay that way.

A TEMPLATE is a JSON document whose string values can contain JSONPath expressions in ${…} placeholders, resolved against the event payload. Everything else is copied as written.

Template
{
"id": "${$.orderId}",
"customer": {
"email": "${$.customer.email}",
"plan": "${$.subscription.plan_name}"
},
"amount_cents": "${$.amount}",
"note": "Order ${$.orderId} from Railhook"
}
Written as Result
A string that is exactly one expression, "${$.amount}" The referenced value with its type: a number stays a number, an object stays an object.
An expression inside other text, "Order ${$.orderId} from Railhook" A string, with the value substituted in.
A path that matches nothing null for a whole-value expression, and the text null inside a longer string. Not an error.

A template can read and rearrange the payload. It cannot loop, branch, call out or compute; use JAVASCRIPT for that.

A JAVASCRIPT transformation is one file that defines one function:

The shape of every script
function handler(webhook) {
return { payload: { id: webhook.eventId } };
}

handler is the only name Railhook calls. Helper functions and constants in the file run before it. Any form that leaves handler defined as a function works, including const handler = (webhook) => ({ payload: webhook.payload });.

Property What it is
payload The event payload, already parsed. An object, an array, or whatever the event body was.
eventType The event type, or the provider’s event type for an incoming webhook.
eventId The event’s id.
timestamp When the event was announced, as an ISO-8601 string.
direction OUTGOING for a delivery, INCOMING for a forwarded webhook.
url The endpoint or destination URL this attempt is aimed at. Readable, not writable.
headers The custom headers configured on the endpoint or destination, not Railhook’s own and not the finished request. The signature, ids and sequence number are computed after the script runs and are not shown, so a script cannot leak a signature. In a preview it is the customHeaders you sent.
attemptNumber Which try this is, 1 for the first. null in a preview, where there is no attempt.

The script can reach nothing else.

Return Effect
{ payload: … } payload is serialised to JSON and becomes the body that is signed and sent.
{ payload: …, headers: { … } } Those headers are added to the request. Values are stringified; a null or a nested object is dropped.
{ cancel: true, cancelReason: '…' } Nothing is sent and the delivery ends. See When a script cancels.

payload is required unless you are cancelling. Anything else (no return, a string, an object with neither payload nor cancel) is a contract failure, and the attempt fails.

Strict mode, ECMAScript 2023, one file, one synchronous function:

  • no modules, no require, no import, no module.exports;
  • no async, no await, no promises to wait on;
  • no state between runs. Each run gets a fresh global, so a top-level variable is back at its initial value on the next delivery.

console.log, console.info, console.warn, console.error and console.debug are captured and shown next to the output in Transform Studio, including lines logged before the script threw.

  • The URL. Railhook checks the address against its SSRF rules before the script runs, so a script that could rewrite it would bypass that check and could send your signed traffic anywhere. webhook.url is readable; assigning to it does nothing.
  • The HTTP method. An attempt is a POST, and no return value changes that.
  • Secrets. A script has no secret store. To authenticate to a receiver, use the endpoint’s or destination’s own credentials (signing secret, mTLS client certificate, destination auth config). They are encrypted at rest and applied after the script runs, so a script can neither read nor overwrite them.
  • Railhook’s own headers. Script headers go on first, then the endpoint’s or destination’s credentials and custom headers; the signature is computed outside that merge. A script may add a header, not override one.

A script belongs to one organization but runs on workers shared by all of them, so it gets a language and nothing else: no filesystem, no network, no threads, no Java classes.

Five ceilings apply to every run. They are deployment settings, not per-transformation ones, because they protect the workers, and a script cannot raise or catch them.

Setting Default What tripping it does
TRANSFORM_SCRIPT_TIMEOUT_MS 2000 The run is stopped and the attempt fails with TIMEOUT. Wall clock for one run.
TRANSFORM_SCRIPT_MAX_MEMORY_MB 128 The run is stopped and the attempt fails with MEMORY. Measured as total allocation, not live memory: churning a gigabyte through the collector costs a worker as much as keeping it.
TRANSFORM_SCRIPT_MAX_OUTPUT_BYTES 1048576 A larger body fails with OUTPUT_TOO_LARGE instead of being sent, so the error names the script, not the endpoint.
TRANSFORM_SCRIPT_MAX_CONSOLE_LINES 100 Nothing fails. Extra lines are dropped, and the result says so.
TRANSFORM_SCRIPT_MAX_CHARS 65536 The script is refused with SOURCE_TOO_LARGE. The API already refuses a longer one on save; this is the backstop.

Set them in .env: see the configuration reference.

Each row is covered by a test.

Attempt What happens
require('fs'), readFully('/etc/passwd'), load('https://…/evil.js') There is no filesystem and no loader. require and load are not defined.
fetch(…), new XMLHttpRequest(), new java.net.Socket(…) There is no network API at all.
new Worker('x') A script cannot create a thread. It runs on exactly the thread that called it and cannot outlive the call.
Java.type('java.lang.System'), Packages.java.lang.System, new java.io.File(…) No Java class can be named, so no repository, no connection pool and no other organization’s row is reachable.
process.env, Polyglot.eval(…) There is no host environment and no second language. process, Polyglot, java, Java, load and similar globals are deleted before your first line runs, so the error is “not defined”.
while (true) {} The run is cancelled from outside, on another thread, once the timeout passes.
while (true) { a.push(new Array(200000)) } The same, once the allocation ceiling passes.

Three things a template cannot do: reshape an array, add a field conditionally, format a date. The parity tests run this exact script in both the preview and the worker.

Transformation
function handler(webhook) {
// 1. Reshape an array — a template cannot loop.
var lines = webhook.payload.items.map(function (item) {
return { sku: item.sku, total: money(item.qty * item.price) };
});
var out = {
order: webhook.payload.id,
lines: lines,
value: money(lines.reduce(function (sum, line) { return sum + line.total; }, 0))
};
// 2. Add a field conditionally — a template cannot branch.
if (out.value >= 100) {
out.review = 'manual';
}
// 3. Format a date — a template cannot compute.
out.day = new Date(webhook.payload.placed_at).toISOString().slice(0, 10);
return { payload: out, headers: { 'X-Order-Value': out.value.toFixed(2) } };
}
function money(amount) {
return Math.round(amount * 100) / 100;
}
Event payload
{
"id": "ord_9001",
"placed_at": "2026-09-20T14:05:09.123Z",
"items": [
{ "sku": "WIDGET", "qty": 2, "price": 19.99 },
{ "sku": "GADGET", "qty": 1, "price": 61.5 }
]
}
Body delivered
{
"order": "ord_9001",
"lines": [
{ "sku": "WIDGET", "total": 39.98 },
{ "sku": "GADGET", "total": 61.5 }
],
"value": 101.48,
"review": "manual",
"day": "2026-09-20"
}

With one header added: X-Order-Value: 101.48.

Both kinds attach in the same places.

Attached to Effect
Subscription, transformationId A saved transformation applied to every delivery the subscription creates.
Subscription, payloadTemplate An inline template, used when no transformationId is set.
Rule, TRANSFORM action Overrides the subscription’s transformation for the events the rule matches. When several matching rules transform, the last one wins. See Rules.
Destination, transformationId A saved transformation applied to forwarded incoming webhooks.
Destination, payloadTransform A single JSONPath expression, such as $.data, used when no transformationId is set.
Workflow, transform node Reshapes data inside a workflow. A script sees the step’s input as its payload and no delivery context. A script that cancels fails the step, since there is no delivery to drop. See Workflows.

The transformation is applied in each attempt, before signing, so the signature covers the transformed bytes the receiver gets.

A saved transformation is looked up when the attempt runs, so fixing a broken one helps the attempts still to come.

A failed script is recorded on the attempt as TRANSFORM_FAILED with a reason and, where known, the line number in your script.

Reason What it means
SYNTAX The script would not compile.
CONTRACT It compiled, but declares no handler, or returned something that is not a valid envelope.
RUNTIME It ran and threw, or reached for something the sandbox does not have.
TIMEOUT It was still running when the wall clock ran out.
MEMORY It allocated past the ceiling.
OUTPUT_TOO_LARGE It returned a body larger than the ceiling.
SOURCE_TOO_LARGE The script itself is longer than the engine will compile.
UNAVAILABLE The engine is not present in this deployment. An operator problem.

{ cancel: true } is not a failure:

  • nothing is sent, so the endpoint’s circuit breaker is not affected;
  • nothing is retried, since the same script on the same payload would cancel again;
  • the delivery ends there with the status CANCELLED, which is neither SUCCESS nor FAILED, recorded as CANCELLED_BY_TRANSFORMATION followed by the cancelReason the script gave, or no reason given;
  • it does not go to Failed Messages, which is for what a person could still fix;
  • it is counted as transform_cancelled_total, separately from transform_failed_total.

Use it for traffic a receiver should not see: test events, a customer who opted out, an event with nothing worth sending.

function handler(webhook) {
if (webhook.payload.livemode === false) {
return { cancel: true, cancelReason: 'test traffic' };
}
return { payload: webhook.payload };
}
  1. Create it. name and template are required; template holds the JSON template or the script, and can be up to 65536 characters. kind is TEMPLATE when omitted.

    Terminal window
    curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/transformations" \
    -H "X-API-Key: $API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name":"Orders to CRM","kind":"JAVASCRIPT","template":"function handler(webhook) { return { payload: { id: webhook.payload.orderId } }; }"}'

    A script is compiled and checked for its handler before it is saved, in the same sandbox and limits as a delivery, so a syntax error is refused here with its line number instead of failing every attempt.

  2. Preview it against a payload you paste in, with POST /api/v1/projects/{projectId}/transform-preview. Send inputPayload and either transformationId or an inline template with its kind. For a script you can also send eventType, eventId, url and customHeaders, which it sees as webhook.*. The response carries the output, headers, console lines, run time and, on failure, the reason and line. Nothing is delivered.

  3. Dry-run a delivery with POST /api/v1/projects/{projectId}/transform-preview/delivery-dry-run. Send the payload, the transformation or payloadTemplate, and an endpointId. The response shows the transformed body, headers and signature that would be sent, plus the transformation name and version. Nothing is sent.

  4. Attach it by setting transformationId on a subscription.

Transform Studio (Develop → Transform Studio, or the edit button on a transformation) runs the worker’s engine with the same sandbox and limits, so a script that works there works on a delivery. It has Input, Output, Diff and Console tabs, runs on ⌘/Ctrl+Enter, highlights the failing line, and saving publishes a new version, as the API does.

Saving a changed template or script publishes a new version. Renaming, editing the description or turning it off does not. Changing only kind counts as a change, because the same text means different things in the two languages.

Every version is kept with its text, its language, who published it and when. In the dashboard, the version number in the transformations list opens the history: read a version, compare two, or restore one. The API has the same operations: see the API reference.

Restoring is not an undo. It publishes the old version as the next version and keeps everything since, so the history of what was live stays true. The new version records which version it came from, and the audit log gets a RESTORE. A restore puts back the text and the language together; otherwise a template restored into a transformation now marked JAVASCRIPT would fail every attempt.

Terminal window
curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/transformations/$TRANSFORMATION_ID/versions/2/restore" \
-H "X-API-Key: $API_KEY"

Transformations created before version history existed start with one version: their current template.