Transformations
Shape what a forward destination receives with a declarative JSON rule: keep, remove, rename, redact, set, template. No code, previewable, and written by any LLM from the published spec.
A transformation is a small JSON document attached to an endpoint's forward destination. It runs when WebhookVault forwards a captured webhook, and shapes only that delivery: what the vault stored stays exactly as it arrived. A destination holds an ordered set of rules, each with its own condition, and every save is a version you can read, compare and restore.
There is no scripting. A rule is a condition plus an ordered pipeline of typed steps, which is why the visual editor on the endpoint page, the API and a model writing the document all produce the same thing.
The document
{
"version": 1,
"name": "invoices only, flattened",
"when": { "all": [ { "path": "body.type", "op": "startsWith", "value": "invoice." } ] },
"steps": [
{ "op": "keep", "paths": ["body.id", "body.type", "body.data.object"] },
{ "op": "rename", "from": "body.data.object.id", "to": "body.invoiceId" },
{ "op": "remove", "paths": ["body.data.object.customer_email"] },
{ "op": "set", "path": "headers.X-Relayed-By", "value": "webhookvault" },
{ "op": "template", "path": "body.summary", "template": "{{body.type}} {{body.invoiceId}}" }
]
}Paths
Every path starts with one of four roots:
| Root | What it addresses | Writable |
|---|---|---|
body | the body parsed as JSON (body.data.object.id, body.items[0].sku, body.items[*].sku) | yes |
headers | request headers, one level, case-insensitive (headers.X-Event) | yes |
query | query-string parameters (query.ref) | yes |
path | the request path as a string | no (conditions and templates only) |
Body steps apply only when the body is JSON. On a binary or non-JSON body they are skipped with a note; header and query steps still apply.
Condition
when is all, any or not over predicates { "path", "op", "value" }. Ops: equals,
notEquals, startsWith, endsWith, contains, exists, gt, gte, lt, lte,
matches (a regular expression run on a linear-time engine: no backreferences or lookarounds).
Omit when to apply the rule to every delivery. A rule that does not match delivers the
request unchanged.
Steps
| Op | Fields | Effect |
|---|---|---|
keep | paths | keep only these paths inside their root |
remove | paths | delete these paths (headers alone empties all headers) |
rename / move | from, to | move a value, across roots if wanted |
set | path, value | write any JSON value, creating containers |
default | path, value | like set, only when the path is absent |
redact | paths, with | replace values with a fixed string (default ***) |
template | path, template | write a string from {{path}} placeholders |
parseJson | path | turn a JSON-text string field into the parsed value |
toString | path | turn a value into its JSON text |
compute | path, fn, args | write a value computed from the payload |
Any step may carry "enabled": false to keep it in the document but skip it. Steps run in
order and see the result of earlier steps. A step that cannot apply (missing path) is a no-op:
the delivery still goes out, and the attempt records the note.
Bounds: 64 steps, 32 KB per document, 64 paths per step, 8 KB string values, 200-character patterns.
compute
Every other step copies, moves or writes a constant. compute is the one that works something
out: the total of a line-item array, cents turned into a currency amount, an idempotency key
hashed from a reference.
It is not an expression language. fn names one function from a fixed table, and args is
a list where each entry is one of exactly three things:
- a path, written the way paths are written everywhere else:
body.amount_cents - a literal: any other JSON value
- a nested call:
{"fn": "...", "args": [...]}
There are no operators, no variables and no control flow, so a rule cannot loop and its cost is
known before it runs. A string argument is read as a path when it parses as one; write
{"literal": "body.x"} when you mean the text rather than the path.
{
"op": "compute",
"path": "body.total",
"fn": "round",
"args": [
{ "fn": "div", "args": ["body.amount_cents", 100] },
2
]
}The functions
| Group | Functions |
|---|---|
| Text | lower upper trim slice replace split concat padStart |
| Numbers | add sub mul div round abs min max |
| Collections | sum count avg first last join |
| Time | now unixToIso isoToUnix |
| Encoding | base64 base64Decode sha256Hex |
| Absence | coalesce ifEmpty |
The collection functions read a path with [*] in it, so sum over body.items[*].amount
adds every line. A path landing on an array means that array's elements, so
count over body.items counts them.
When a value is not there
A missing path is absent, and absent is not zero. Arithmetic on a missing field produces absent rather than a number, so a field the sender left out can never quietly become a real value in a payment payload. A type mismatch and a division by zero are absent too, with a note on the attempt rather than an error.
This holds for the collection functions as well: count of a missing path is absent, not 0,
and join of one is absent, not an empty string. Zero and the empty string belong to a path
that is there and holds nothing. concat is absent if any argument is missing, so a key built
from two fields is never silently built from one.
A numeric string is read only when it is unambiguous. "12.5" is a number; "1,234" is not,
because a comma means a thousands separator to one sender and a decimal point to another, and
guessing wrong is a hundredfold error.
When the whole call is absent the target path is left exactly as it was, the same way rename
leaves a missing source alone. The delivery still goes out. Use coalesce to opt into a
default:
{ "op": "compute", "path": "body.currency", "fn": "coalesce", "args": ["body.currency", { "literal": "USD" }] }Bounds. The shape of a rule is checked when you save it: 4 levels of nesting, 8 arguments per call, 16 calls per step, and 8 KB on any literal.
The rest is checked while the rule runs, because they depend on the payload rather than the rule: 1000 elements from any one path read, 5000 elements across a whole step, and 8 KB on the result. A step that reaches one of these stops and says so on the attempt, leaving the path untouched, rather than writing a partial answer.
When a rule keeps failing
A rule that cannot be applied to one delivery is normal: the path was not there, the payload was not JSON, the numbers did not add up. The delivery still goes out, unchanged by that rule, and the attempt records that it was skipped.
A rule that fails on every delivery is different. After five in a row WebhookVault switches it off, so the rest of the set keeps working and your deliveries stop paying for a rule that cannot succeed. When that happens:
- the rule shows as off in the editor, with the reason in place of its condition
- an entry appears in the workspace audit log
- an alert goes out on the A transformation is switched off event, if you subscribe to it
Any delivery the rule handles without failing resets the count, so an occasional odd payload never adds up to a switch-off. Editing the rule, restoring an earlier version, or turning it back on all start the count again from zero.
The attempt tells you a rule was skipped; it does not tell you which internal error caused it, because that text can carry fragments of the payload. The detail is in our logs. If a rule is switched off and the reason is not obvious, the Preview panel runs it against a real captured request and will show you the same failure.
Sets: several rules on one destination
A destination holds up to 20 rules in a fixed order. On every delivery the enabled rules run
top to bottom; each one applies when its own when matches and sees the result of the rules
above it. That is how one destination can strip credentials on every delivery, flatten
invoices only, and add a summary line only for refunds: three small rules, each with its own
condition, each switched on or off on its own.
A disabled rule stays in the set and is skipped; the delivery attempt says so. A rule that does not match, or one whose document no longer validates, never blocks a delivery.
Versions
Every changed save is a new version of that rule; saving an identical document is not. The History button on the Transform tab lists every version with who saved it and when; pick one to read it, compare it line by line with the current version, load it into the editor without saving, or restore it. A restore saves that document as the next version, so the version it replaces stays in the history. The API exposes the same list and restore.
Where it shows
- Endpoint page, Transform tab: the set on the left (order, condition, version, on/off), the editor on the right. Pick a stored capture as the sample, click values in its tree to insert paths, build the pipeline as cards, and watch the preview update. "Preview the whole set" runs the saved rules in order instead of the rule in the editor. "View as JSON" shows the document itself, which is what you copy, share, or hand to a model. "Ask an LLM" copies the spec, a redacted sample, the other rules in the set and your intent to the clipboard.
- Deliveries tab: every attempt records what the rules did, per rule and version, next to exactly what was sent and exactly what came back. See Deliveries.
- Replay: replays use the set as it is now, not as it was when the request arrived.
API
| Call | Purpose |
|---|---|
GET /api/v1/transformations/schema | the JSON Schema of the grammar |
POST /api/v1/transformations/preview | validate and run one rule against sampleRequestId or an inline sampleRequest |
GET /api/v1/endpoints/{id}/transformations | the set, in run order |
POST /api/v1/endpoints/{id}/transformations | add a rule at the end (body = the document) |
PUT /api/v1/endpoints/{id}/transformations/{ruleId} | replace its document ({ "rule": … }) and/or set enabled |
DELETE /api/v1/endpoints/{id}/transformations/{ruleId} | remove it and its history |
PUT /api/v1/endpoints/{id}/transformations/order | reorder ({ "order": [ids…] }) |
GET …/transformations/{ruleId}/versions | every version, newest first |
POST …/transformations/{ruleId}/versions/{n}/restore | restore version n as the next version |
POST /api/v1/endpoints/{id}/transformations/preview-set | run the saved set against sampleRequestId |
POST answers 400 with an errors list when the document is invalid, 403 feature_not_in_plan
on Free and Solo, 409 no_destination when the endpoint has no forward destination yet, and
409 limit_reached at 20 rules. The singular …/endpoints/{id}/transformation routes from the
first release keep working and address the first rule of the set.
For models
The complete grammar, every op, six worked examples and the exact API calls live in one plain
text file written for models: /llms-transformations.txt.
The recommended loop is: write the document, preview it against a real sampleRequestId,
read every step note, adjust, then PUT.
Rate limits
Per-key API budgets by plan, honest 429s with Retry-After, and the capture-side limits that protect your endpoints.
Deliveries
Every forward attempt with exactly what left the vault and exactly what came back: method, URL, headers and body both ways, status, timing, and what the transformation rules did.