# WebhookVault transformations: the rule spec for models (v1) You are writing a WebhookVault transformation rule. A rule is a JSON document, never code. It runs when WebhookVault forwards a captured webhook to a destination URL, and shapes what the destination receives. The stored capture is never changed. A rule that does not match, or a step that cannot apply, never blocks a delivery: it is a no-op with a note on the delivery attempt. Output ONLY the JSON document unless asked otherwise. Validate it with the preview API before saving (calls at the end). ## Document { "version": 1, "name": "short human name (optional, 120 chars max)", "when": , "steps": [ , ... ] (0..64 steps, run in order) } Bounds: document 32 KB max, 64 steps, 64 paths per step, path 300 chars, string values 8 KB, regex 200 chars (no backreferences, no lookarounds). Unknown keys are rejected. ## Paths A path is a dotted string starting with one of four roots: - body the request body parsed as JSON. Children by key: body.data.object.id Arrays by index or wildcard: body.items[0].sku, body.items[*].sku - headers request headers, one level, case-insensitive names: headers.X-Event, headers.content-type - query query-string parameters, one level: query.ref - path the request path as a string (read-only: usable in conditions and templates only) Rules for bodies: body steps apply only when the body is JSON. On a binary or non-JSON body every body step is skipped with a note; header and query steps still apply. Values written under headers or query become strings. A path whose containers are missing is created by set/default/move/template. ## Condition (when) A condition is one of: { "all": [ , ... ] } every child matches (empty = true) { "any": [ , ... ] } at least one child matches { "not": } { "path": "", "op": "", "value": } a predicate Predicate ops: equals, notEquals deep equality, or equality of the value's text form startsWith, endsWith, contains on the text form (contains on an array = membership) exists the path resolves to a value (no "value" needed) gt, gte, lt, lte numeric comparison (numbers or numeric strings) matches regular expression on the text form (linear-time engine) A missing path fails every op except notEquals (true) and exists (false). With [*] the predicate holds when any element satisfies it. ## Steps Every step has "op" and may have "enabled": false (kept in the document, skipped) and "note" (free text, ignored by the engine). { "op": "keep", "paths": ["body.id", "body.data.object", "headers.X-Event"] } Keep only these paths inside their root; everything else under that root is dropped. Roots not mentioned are untouched. Paths must be below a root. { "op": "remove", "paths": ["body.data.object.customer_email", "headers.Authorization"] } Delete these paths. "headers" alone empties all headers. { "op": "rename", "from": "body.data.object.id", "to": "body.invoiceId" } { "op": "move", "from": "body.id", "to": "headers.X-Event-Id" } Move a value (rename and move are the same op; use whichever reads better). Works across roots. Missing "from" = no-op. No [*] here. { "op": "set", "path": "headers.X-Relayed-By", "value": "webhookvault" } Write any JSON value at the path (objects and arrays allowed under body). { "op": "default", "path": "body.currency", "value": "ZAR" } Like set, only when the path is absent. { "op": "redact", "paths": ["body.card.last4", "body.customer.email"], "with": "***" } Replace values with a fixed string ("with" defaults to "***", 64 chars max). { "op": "template", "path": "body.summary", "template": "{{body.type}} for {{body.data.object.id}}" } Write a string built from {{path}} placeholders. Placeholders are path lookups only; no logic, no filters. A missing placeholder renders empty (noted). { "op": "parseJson", "path": "body.payload" } Turn a string field holding JSON text into the parsed value. { "op": "toString", "path": "body.metadata" } Turn a value into its JSON text (objects and arrays serialised). { "op": "compute", "path": "body.total", "fn": "round", "args": [ { "fn": "div", "args": ["body.amount_cents", 100] }, 2 ] } Write a value COMPUTED from the payload. This is NOT an expression language: "fn" names one function from the fixed table below, and each entry of "args" is exactly one of three things: - a path, written as paths are written everywhere else ("body.amount_cents") - a literal (any other JSON value) - a nested call ({ "fn": ..., "args": [...] }) There are no operators, variables, loops or conditionals. A string argument is read as a path when it parses as one; write { "literal": "body.x" } for the text instead. 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 Collection functions read a [*] path: sum over "body.items[*].amount" adds every line. A path landing on an array means that array's elements. ABSENT IS NOT ZERO. A missing path, a type mismatch and a division by zero all produce absent; arithmetic on absent is absent, so a field the sender omitted can never become a real number in the payload. count of a missing path is absent, not 0; join of one is absent, not ""; concat is absent if ANY argument is missing. Zero and the empty string belong to a path that exists and holds nothing. When the whole call is absent the target path is left exactly as it was and the delivery still goes out. Use coalesce to opt into a default. A numeric string is read only when unambiguous: "12.5" is a number, "1,234" is not, because a comma is a thousands separator to one sender and a decimal point to another. Bounds checked AT SAVE (the rule's shape): 4 levels of nesting, 8 args per call, 16 calls per step, 8 KB per literal. Bounds checked WHILE RUNNING (they depend on the payload): 1000 elements per path read, 5000 elements per step, 8 KB result. Reaching one leaves the path untouched and notes it on the attempt rather than writing a partial answer. Order matters: steps see the result of earlier steps. After a body step the delivery's Content-Type becomes application/json if it was not JSON already. ## Worked examples 1. Stripe: forward only invoice events, flattened, without PII { "version": 1, "name": "invoices only, flattened", "when": { "all": [ { "path": "body.type", "op": "startsWith", "value": "invoice." } ] }, "steps": [ { "op": "keep", "paths": ["body.id", "body.type", "body.created", "body.data.object"] }, { "op": "rename", "from": "body.data.object.id", "to": "body.invoiceId" }, { "op": "rename", "from": "body.data.object.amount_paid", "to": "body.amountPaid" }, { "op": "remove", "paths": ["body.data.object.customer_email", "body.data.object.customer_name"] }, { "op": "template", "path": "body.summary", "template": "{{body.type}} {{body.invoiceId}} {{body.amountPaid}}" } ] } Note: keep runs first, so later paths refer to what survived. 2. GitHub: only pushes to main, add a routing header, drop the huge commits array { "version": 1, "name": "pushes to main", "when": { "all": [ { "path": "headers.X-GitHub-Event", "op": "equals", "value": "push" }, { "path": "body.ref", "op": "equals", "value": "refs/heads/main" } ] }, "steps": [ { "op": "remove", "paths": ["body.commits", "body.head_commit.added", "body.head_commit.removed"] }, { "op": "set", "path": "headers.X-Route", "value": "deploy" } ] } 3. Shopify: redact addresses, keep the topic reachable { "version": 1, "name": "orders without addresses", "steps": [ { "op": "redact", "paths": ["body.shipping_address", "body.billing_address", "body.customer.email"], "with": "[redacted]" }, { "op": "move", "from": "headers.X-Shopify-Topic", "to": "body.topic" } ] } 4. Unwrap a stringified payload and default a field { "version": 1, "name": "unwrap payload", "steps": [ { "op": "parseJson", "path": "body.payload" }, { "op": "default", "path": "body.payload.source", "value": "unknown" } ] } 5. Array wildcard in a condition, header from a query parameter { "version": 1, "name": "big orders", "when": { "any": [ { "path": "body.items[*].quantity", "op": "gte", "value": 100 }, { "path": "query.priority", "op": "equals", "value": "high" } ] }, "steps": [ { "op": "move", "from": "query.priority", "to": "headers.X-Priority" } ] } 6. Strip everything but two headers and a summary string { "version": 1, "name": "minimal", "steps": [ { "op": "keep", "paths": ["headers.Content-Type", "headers.X-Request-Id"] }, { "op": "template", "path": "body.line", "template": "{{path}} {{body.event}} {{body.id}}" }, { "op": "keep", "paths": ["body.line"] } ] } ## API (bearer API key, base https://app.webhookvault.net) Schema: GET /api/v1/transformations/schema Preview: POST /api/v1/transformations/preview { "rule": , "sampleRequestId": 123 } a stored request in the workspace { "rule": , "sampleRequest": { "method": "POST", "headers": {...}, "body": "", "contentType": "application/json" } } -> 200 { matched, output: { headers, body, contentType, queryString }, steps: [{ index, op, applied, note }], note } -> 400 problem+json with "errors": [ ... ] when the document is invalid A destination holds an ORDERED SET of rules (up to 20). Every delivery runs the enabled rules top to bottom; each applies when its own "when" matches and sees the result of the rules above it. Every changed save is a new version; versions can be viewed and restored. Set: GET /api/v1/endpoints/{endpointId}/transformations -> 200 { destinationId, rules: [{ id, position, name, enabled, condition, rule, version }], maxRules, featureInPlan } Add: POST /api/v1/endpoints/{endpointId}/transformations body = the document itself -> 201 rule | 400 errors | 403 feature_not_in_plan (Pro and up) | 409 no_destination | 409 limit_reached Replace: PUT /api/v1/endpoints/{endpointId}/transformations/{ruleId} body = { "rule": } and/or { "enabled": false } -> 200 rule (version increments when the document changed) Remove: DELETE /api/v1/endpoints/{endpointId}/transformations/{ruleId} -> 204 Order: PUT /api/v1/endpoints/{endpointId}/transformations/order body = { "order": [ruleId, ...] } (every id, once) History: GET /api/v1/endpoints/{endpointId}/transformations/{ruleId}/versions Restore: POST /api/v1/endpoints/{endpointId}/transformations/{ruleId}/versions/{n}/restore (saved as the next version) Set run: POST /api/v1/endpoints/{endpointId}/transformations/preview-set body = { "sampleRequestId": 123 } Compat: GET/PUT/DELETE /api/v1/endpoints/{endpointId}/transformation addresses the FIRST rule only. curl example: curl -X POST https://app.webhookvault.net/api/v1/endpoints/ENDPOINT_ID/transformations \ -H "Authorization: Bearer wv_live_..." -H "Content-Type: application/json" \ --data @rule.json Loop: write the document, POST preview with a real sampleRequestId, read every step note, fix, repeat, then POST it to the set (or PUT it onto an existing rule). Deliveries from the next attempt on (replays included) use the new version; each delivery attempt records "transformed" and a note per rule and version. Prefer several small rules with clear conditions over one large rule: each one is versioned and can be switched off on its own. Delivery record: GET /api/v1/endpoints/{endpointId}/deliveries/{attemptId} returns exactly what was sent after the rules ran (headers, body) and exactly what the destination answered. ## JSON Schema The same grammar as a JSON Schema (2020-12) is served at GET /api/v1/transformations/schema with $id https://app.webhookvault.net/schemas/transformation-v1.json.