> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Function node: HTTP, webhooks & schema validation

> Reference for the ModuleX function node and its four built-in functions — http_request, send_webhook, validate_schema, and validate_workflow_schema — with every parameter, input/output shape, error, and a worked example.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

The `function` node runs one of ModuleX's **built-in registry functions** as a step in your workflow. It is the node you reach for when you need to call an external HTTP API, deliver a signed webhook, or validate data against a JSON Schema — without writing a custom integration or an LLM prompt.

A function node is configured with a `FunctionNodeConfig` and dispatched to exactly one registry function by name. There are exactly four built-in functions:

| Function name              | Category    | What it does                                                              |
| -------------------------- | ----------- | ------------------------------------------------------------------------- |
| `http_request`             | integration | Make an HTTP request (GET/POST/PUT/PATCH/DELETE) to any URL.              |
| `send_webhook`             | integration | Deliver a signed, retrying webhook with standard headers.                 |
| `validate_schema`          | validation  | Validate data against a JSON Schema (Draft-7).                            |
| `validate_workflow_schema` | validation  | Validate a workflow package against ModuleX's `WorkflowDefinition` model. |

The function registry is fixed: these four are the only callable functions. To call a third-party service that has a dedicated integration, use the [tool node](/workflow-builder/nodes/tool) instead; to run a custom external service, connect a [custom MCP server](/integrations/building/custom-mcp). For where the function node sits among the nine node kinds, see the [node types overview](/workflow-builder/nodes/overview).

<Note>
  Use the function node's `http_request` for ad-hoc calls to APIs that ModuleX does not have a first-class integration for. When an [integration](/integrations/overview) already exists for the service, prefer the [tool node](/workflow-builder/nodes/tool) — it handles authentication, credentials, and typed parameters for you.
</Note>

<MediaEmbed id="MX-MEDIA-3110" type="screenshot" caption={"the function node's detail panel in the workflow builder, showing the function picker and the parameter form for `http_request`."} />

## How the function node works

The function node separates configuration into two channels, both verified against the engine dispatch (`modulex:py/app/services/workflow_engine.py:2145`):

* **`input_mapping`** — runtime data resolved from earlier nodes via [references](/workflow-builder/variables-and-references). Each `{{node_id.path}}` reference is resolved against run state and passed to the function as its `inputs` argument. This is the data the function operates on (the body to send, the data to validate).
* **`parameters`** — static configuration for the function, passed as its `params` argument (the URL, the HTTP method, the JSON Schema). These are set once when you build the node and are not resolved from run state.

At runtime the node:

1. Resolves `input_mapping` against state into an `inputs` dictionary. References that resolve to `None` or `""` are skipped. If `input_mapping` is empty, the deprecated `input_keys` list is used as a fallback (`workflow_engine.py:2166`).
2. Reads `parameters` (the static config) into `params`.
3. Looks up the function by `function_name` in the registry. An unknown name raises `ValueError` at graph-build time, listing the available functions (`workflow_engine.py:2152`).
4. Calls `func.execute(inputs, params)` and writes the result into run state under the node's own `id`.

<Warning>
  The two `{{ }}` styles are not the same. References **between nodes** use the workflow engine's `{{node_id.path}}` syntax and are resolved in `input_mapping`. Inside `http_request` and `send_webhook`, the literal `{key}` placeholder (single braces) is a separate, function-local templating step that substitutes values from the resolved `inputs` dictionary into URL strings and body/payload fields. See [variables & references](/workflow-builder/variables-and-references) for the engine reference model.
</Warning>

### Output convention

Like every node, the function node writes its result to run state under its node `id` (`workflow_engine.py:2163`). Downstream nodes read it with a reference such as `{{node_http_1.body}}` or `{{node_validate_1.valid}}`. The shape stored depends on the outcome:

| Outcome                            | Stored under the node id                                                                                                                        |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Success (`result.success == true`) | The function's `data` object verbatim (e.g. `{status_code, headers, body, url}`).                                                               |
| Function reported failure          | `{error, success: false, …result.data}` — the error string plus whatever partial `data` the function returned (e.g. the validation error list). |
| Unhandled exception                | `{error, success: false}` — only the stringified exception.                                                                                     |

So when a function fails, `{{node_id.success}}` is `false` and `{{node_id.error}}` holds the message. You can branch on this with a [conditional node](/workflow-builder/nodes/conditional) — for example, route on `{{node_http_1.success}}` equals `false`.

### Retries and error handling

Function nodes are retry-wrapped by the engine (`workflow_engine.py:1245`), but with an important caveat: the node-level retry only re-runs the step when the underlying function **raises an exception**. The four built-ins catch their own errors and return a `FunctionResult` with `success: false` instead of raising. That result is stored as the node output — it does **not** trigger a node-level retry.

What this means in practice:

* A failed `http_request` (timeout, connection error, non-2xx) returns `success: false` and is **not** retried by the node's `retry_config`. Handle it downstream with a conditional, or use `send_webhook` (which has its own internal retry loop) when you need delivery retries.
* `send_webhook` retries 5xx responses and timeouts internally up to its `retry_count` (capped at 5) with exponential backoff — independent of the node's `retry_config`.
* Node-level `retry_config` (`max_attempts` 1-10, `initial_interval`, `backoff_factor`, `retry_on_error_types`) still applies to truly unexpected exceptions. See [error handling & retries](/workflow-builder/error-handling-retries).

<Note>
  The function node does not call a language model and does not retrieve managed knowledge, so it incurs **no ModuleX credits** on its own. Costs come only from what you call: `http_request` and `send_webhook` hit external endpoints (billed by that service, not ModuleX), and the validation functions run locally. Credits are metered for LLM, agent, and managed-knowledge work — see [credits & metering](/billing/credits).
</Note>

## Configuration: `FunctionNodeConfig`

Source: `modulex:py/app/models/workflow_schemas.py:179`.

<ParamField path="function_name" type="string" required>
  The name of the built-in function to run. Must be one of `http_request`, `send_webhook`, `validate_schema`, or `validate_workflow_schema`. An unknown name fails graph compilation with a `ValueError` listing the available functions.
</ParamField>

<ParamField path="input_mapping" type="object" default="{}">
  Maps function input keys to values, resolved from run state. Use `{{node_id.path}}` [references](/workflow-builder/variables-and-references) to pull values from earlier nodes. A pure `{{ref}}` value keeps its native type; mixed strings are templated. References resolving to `None` or `""` are skipped. The resolved dictionary becomes the function's `inputs`.
</ParamField>

<ParamField path="parameters" type="object">
  Static configuration passed to the function as its `params` (the URL, method, schema, and so on — see each function below). Not resolved from run state.
</ParamField>

<ParamField path="input_keys" type="string[]" default="[]" deprecated>
  Legacy. A list of state keys to collect into `inputs` when `input_mapping` is empty. Use `input_mapping` with references instead.
</ParamField>

<ParamField path="output_key" type="string" deprecated>
  Legacy and ignored. Output is always stored under the node `id`.
</ParamField>

A minimal config selecting a function and wiring one input looks like this:

```json Function node config theme={null}
{
  "id": "node_validate_1",
  "type": "function",
  "name": "Validate order payload",
  "x": 480,
  "y": 220,
  "function_config": {
    "function_name": "validate_schema",
    "input_mapping": {
      "order": "{{node_parse_1.body}}"
    },
    "parameters": {
      "schema": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "total": { "type": "number" }
        },
        "required": ["id", "total"]
      },
      "data_key": "order",
      "strict": false
    }
  }
}
```

<Note>
  The function builder UI accepts the wrapped `{ "config": { … } }` form and normalizes it to `function_config`; either shape is valid on the wire (`workflow_schemas.py:686`).
</Note>

## Built-in functions

Each function defines its parameters as a list of typed `ParameterDefinition`s (`modulex:py/app/services/functions/base.py:23`). Parameter types are `string`, `number`, `boolean`, `object`, `array`, or `select` (a dropdown with fixed options). A required parameter with no default that is missing fails the function with `Missing required parameter: <name>`.

### `http_request`

Make an HTTP request to an external API. Source: `modulex:py/app/services/functions/http_request.py:24`. Category: `integration`.

#### Parameters (static `parameters`)

<ParamField path="url" type="string" required>
  The URL to send the request to. Supports `{key}` placeholders substituted from the resolved `inputs` (for example `https://api.example.com/users/{user_id}`).
</ParamField>

<ParamField path="method" type="select" default="GET">
  HTTP method. One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. Lower-cased input is upper-cased automatically.
</ParamField>

<ParamField path="headers" type="object" default="{}">
  Custom request headers, for example `{"Authorization": "Bearer token"}`. A string value is parsed as JSON, then as a Python literal, falling back to an empty object if neither parses.
</ParamField>

<ParamField path="body" type="object">
  Request body for `POST`, `PUT`, and `PATCH`. A value of exactly `{key}` is replaced by that input's native value (preserving objects and arrays); other strings are templated. Ignored for `GET`/`DELETE`. If `body` is `null` and `use_inputs_as_body` is unset, the resolved `inputs` are used as the body.
</ParamField>

<ParamField path="use_inputs_as_body" type="boolean" default="false">
  If `true`, the entire resolved `inputs` dictionary is used as the request body, ignoring `body`.
</ParamField>

<ParamField path="body_type" type="select" default="json">
  Content encoding for the body. One of `json`, `form`, or `text`.
</ParamField>

<ParamField path="timeout" type="number" default="30">
  Request timeout in seconds.
</ParamField>

<ParamField path="auth_type" type="select" default="none">
  Authentication scheme. One of `none`, `bearer`, `basic`, or `api_key`.
</ParamField>

<ParamField path="auth_value" type="string">
  The token, password, or API key value. For `bearer` it becomes `Authorization: Bearer <auth_value>`; for `api_key` it is sent under `api_key_header`; for `basic` it is the password.
</ParamField>

<ParamField path="auth_username" type="string">
  Username for `basic` auth (paired with `auth_value` as the password).
</ParamField>

<ParamField path="api_key_header" type="string" default="X-API-Key">
  Header name used to send the API key when `auth_type` is `api_key`.
</ParamField>

<ParamField path="follow_redirects" type="boolean" default="true">
  Whether to follow HTTP redirects.
</ParamField>

<ParamField path="verify_ssl" type="boolean" default="true">
  Whether to verify the server's TLS certificate.
</ParamField>

<Warning>
  `http_request` is a general outbound HTTP client. Treat `auth_value`, tokens, and any secret headers as sensitive: set them in `parameters`, and avoid logging response bodies that may echo them back. Do not point `url` at internal-only addresses you do not control.
</Warning>

#### Output

On a 2xx response, `success` is `true` and the node stores `data`:

<ResponseField name="status_code" type="number">
  The HTTP status code.
</ResponseField>

<ResponseField name="headers" type="object">
  The response headers as a flat object.
</ResponseField>

<ResponseField name="body" type="object | string">
  The parsed JSON response body, or the raw text if the response is not JSON.
</ResponseField>

<ResponseField name="url" type="string">
  The final request URL (after any redirects).
</ResponseField>

The function also returns `metadata` (`{method, elapsed_ms}`); metadata is not stored in run state.

A non-2xx status sets `success` to `false` and `error` to `HTTP <status_code>`, while still returning the same `data` body — so you can branch on the status. Transport failures return `success: false` with `error` of `Request timeout after <n> seconds` (timeout) or `Request failed: <detail>` (connection/request error). An unexpected error returns `Unexpected error: <detail>`.

### `send_webhook`

Deliver a webhook with standard headers, optional HMAC signing, and internal retries. Source: `modulex:py/app/services/functions/send_webhook.py:27`. Category: `integration`. Always sent as `POST` with `Content-Type: application/json`.

#### Parameters (static `parameters`)

<ParamField path="url" type="string" required>
  The webhook endpoint URL.
</ParamField>

<ParamField path="event_type" type="string" required>
  An event-type identifier, for example `order.created` or `workflow.completed`. Sent in the `X-Webhook-Event` header and, when metadata is included, in the payload.
</ParamField>

<ParamField path="payload" type="object">
  The webhook payload. If omitted (`null`), the resolved `inputs` are used as the payload. String values containing `{key}` placeholders are templated from `inputs`.
</ParamField>

<ParamField path="signing_secret" type="string">
  A secret key. When set, the payload is signed with HMAC-SHA256 and the signature is sent as `X-Webhook-Signature: sha256=<hex>` (see signing below).
</ParamField>

<ParamField path="headers" type="object" default="{}">
  Additional custom headers, merged on top of the standard webhook headers.
</ParamField>

<ParamField path="timeout" type="number" default="30">
  Per-attempt request timeout in seconds.
</ParamField>

<ParamField path="retry_count" type="number" default="3">
  Number of retry attempts on retriable failure. Capped at `5`; values above 5 are clamped.
</ParamField>

<ParamField path="retry_delay" type="number" default="1">
  Base delay in seconds between retries. Each retry waits `retry_delay × 2^attempt`, capped at 30 seconds.
</ParamField>

<ParamField path="include_metadata" type="boolean" default="true">
  When `true`, the delivered body is wrapped as `{webhook_id, event_type, timestamp, data}` with your payload under `data`. When `false`, the payload is sent as-is.
</ParamField>

#### Standard headers and signing

Every delivery includes these headers (your custom `headers` are merged on top):

```text Standard webhook headers theme={null}
Content-Type: application/json
X-Webhook-ID: <uuid4>
X-Webhook-Timestamp: <unix-seconds>
X-Webhook-Event: <event_type>
X-Webhook-Signature: sha256=<hmac>   # only when signing_secret is set
```

The signature is `HMAC-SHA256(key = signing_secret, message = "<timestamp>.<payload_json>")`, hex-encoded and prefixed with `sha256=`. Receivers should recompute the HMAC over the same `timestamp.payload` string to verify authenticity.

<Note>
  A `5xx` response or a timeout is retried up to `retry_count` times with exponential backoff. A `4xx` response is treated as non-retriable and fails immediately. These are the function's own retries and are independent of the node's `retry_config`.
</Note>

#### Output

On a 2xx delivery, `success` is `true` and the node stores `data`:

<ResponseField name="webhook_id" type="string">
  The generated webhook id (a UUID4), matching the `X-Webhook-ID` header.
</ResponseField>

<ResponseField name="status_code" type="number">
  The HTTP status returned by the endpoint.
</ResponseField>

<ResponseField name="response" type="object | string">
  The endpoint's response body, parsed as JSON when possible, otherwise raw text.
</ResponseField>

<ResponseField name="attempts" type="array">
  Per-attempt records, each `{attempt, status_code | error, elapsed_ms}`.
</ResponseField>

<ResponseField name="delivered_at" type="number">
  The unix timestamp used for the delivery and signature.
</ResponseField>

A `4xx` failure returns `success: false`, `error` of `Webhook delivery failed: HTTP <code>`, and `data` with `{webhook_id, status_code, response (truncated to 500 chars), attempts}`. When all retries are exhausted, `error` is `Webhook delivery failed after <n> attempts: <last_error>` with `data` of `{webhook_id, attempts}`.

### `validate_schema`

Validate data against a JSON Schema (Draft-7). Source: `modulex:py/app/services/functions/validate_schema.py:28`. Category: `validation`.

#### Parameters (static `parameters`)

<ParamField path="schema" type="object" required>
  The JSON Schema definition to validate against, for example `{"type": "object", "properties": {…}}`.
</ParamField>

<ParamField path="data_key" type="string">
  The key in the resolved `inputs` to validate. If unset, the entire `inputs` dictionary is validated. A `data_key` that is not present in `inputs` fails with `Input key '<key>' not found in inputs`.
</ParamField>

<ParamField path="strict" type="boolean" default="false">
  When `true`, validation stops at the first error. When `false`, all errors are collected and returned together.
</ParamField>

<ParamField path="coerce_types" type="boolean" default="false">
  When `true`, attempts simple type coercion before validating (string→number/integer, string→boolean for `true/1/yes` and `false/0/no`, value→string), recursing into array items and object properties named in the schema.
</ParamField>

#### Output

On valid data, `success` is `true` and the node stores `data`:

<ResponseField name="valid" type="boolean">
  `true` when the data conforms to the schema.
</ResponseField>

<ResponseField name="errors" type="array">
  An empty array on success.
</ResponseField>

<ResponseField name="validated_data" type="object">
  The validated data (after any coercion).
</ResponseField>

On invalid data (non-strict mode), `success` is `false`, `error` is `Validation failed with <n> error(s)`, and `data` carries the detail:

<ResponseField name="valid" type="boolean">
  `false`.
</ResponseField>

<ResponseField name="error_count" type="number">
  The number of validation errors.
</ResponseField>

<ResponseField name="errors" type="array">
  <Expandable title="error entry">
    <ResponseField name="path" type="string">
      The dotted path to the offending field, or `root`.
    </ResponseField>

    <ResponseField name="message" type="string">
      The human-readable validation message.
    </ResponseField>

    <ResponseField name="validator" type="string">
      The JSON Schema keyword that failed (for example `required`, `type`).
    </ResponseField>

    <ResponseField name="value" type="any">
      The offending value, or `...` for nested objects and arrays.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="validated_data" type="null">
  `null` when validation failed.
</ResponseField>

An invalid schema returns `error` of `Invalid schema: <detail>`. If the `jsonschema` library is unavailable, the function returns an error rather than validating.

### `validate_workflow_schema`

Validate a generated workflow package against ModuleX's Pydantic `WorkflowDefinition` model. This is primarily used by the [AI Composer](/concepts/ai-composer) when it generates a workflow, to catch structural errors before the workflow is saved or run. Source: `modulex:py/app/services/functions/validate_workflow_schema.py:23`. Category: `validation`.

#### Parameters (static `parameters`)

<ParamField path="data_key" type="string" default="generated_workflow">
  The key in the resolved `inputs` that holds the workflow package. The package's `.workflow` field (or the value itself, if it is not a package wrapper) is validated.
</ParamField>

#### Behavior

Before Pydantic validation, the function rejects the virtual nodes `__start__` and `__end__` if they appear in the workflow's `nodes` array — these are virtual and belong only in edges. This pre-check produces a `virtual_node_error`. The package is then validated against `WorkflowDefinition` (`workflow_schemas.py:929`).

#### Output

On a valid workflow, `success` is `true` and the node stores `data` with `{valid: true, errors: [], validated_data: <full package>, message: "Workflow schema validation passed"}`.

On an invalid workflow, `success` is `false`, `error` is `Workflow validation failed with <n> error(s)`, and `data` carries:

<ResponseField name="valid" type="boolean">
  `false`.
</ResponseField>

<ResponseField name="error_count" type="number">
  The number of validation errors.
</ResponseField>

<ResponseField name="errors" type="string[]">
  Formatted error strings, each `<path>: <message> (got: <value>)`.
</ResponseField>

<ResponseField name="error_details" type="array">
  <Expandable title="error detail">
    <ResponseField name="path" type="string">
      The dotted path to the offending field.
    </ResponseField>

    <ResponseField name="message" type="string">
      The validation message.
    </ResponseField>

    <ResponseField name="type" type="string">
      The error type (for example `missing`, `string_type`, or `virtual_node_error`).
    </ResponseField>

    <ResponseField name="input" type="string">
      A truncated string form of the offending input.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="validated_data" type="null">
  `null` when validation failed.
</ResponseField>

A missing `data_key`, a missing `workflow` key in the package, or an unexpected error each return `success: false` with a descriptive `error`.

## Worked example: fetch, validate, deliver

This three-node fragment fetches an order from an external API, validates the response body against a JSON Schema, then delivers a signed webhook with the validated data. The first node uses static `parameters` for the URL and method; the second and third pull data from upstream nodes through `input_mapping`.

<CodeGroup>
  ```json node 1 — http_request theme={null}
  {
    "id": "node_fetch_order",
    "type": "function",
    "name": "Fetch order",
    "x": 200,
    "y": 160,
    "function_config": {
      "function_name": "http_request",
      "input_mapping": {
        "order_id": "{{node_input.order_id}}"
      },
      "parameters": {
        "url": "https://api.example.com/orders/{order_id}",
        "method": "GET",
        "auth_type": "bearer",
        "auth_value": "sk_example_token",
        "timeout": 20
      }
    }
  }
  ```

  ```json node 2 — validate_schema theme={null}
  {
    "id": "node_validate_order",
    "type": "function",
    "name": "Validate order",
    "x": 480,
    "y": 160,
    "function_config": {
      "function_name": "validate_schema",
      "input_mapping": {
        "order": "{{node_fetch_order.body}}"
      },
      "parameters": {
        "schema": {
          "type": "object",
          "properties": {
            "id": { "type": "string" },
            "total": { "type": "number" },
            "currency": { "type": "string" }
          },
          "required": ["id", "total"]
        },
        "data_key": "order",
        "strict": false
      }
    }
  }
  ```

  ```json node 3 — send_webhook theme={null}
  {
    "id": "node_notify",
    "type": "function",
    "name": "Notify fulfillment",
    "x": 760,
    "y": 160,
    "function_config": {
      "function_name": "send_webhook",
      "input_mapping": {
        "order": "{{node_validate_order.validated_data}}"
      },
      "parameters": {
        "url": "https://hooks.example.com/fulfillment",
        "event_type": "order.validated",
        "signing_secret": "whsec_example",
        "retry_count": 3,
        "include_metadata": true
      }
    }
  }
  ```
</CodeGroup>

What happens at run time:

1. **`node_fetch_order`** resolves `{{node_input.order_id}}` into `inputs`, substitutes it into the URL via the `{order_id}` placeholder, sends an authenticated GET, and stores `{status_code, headers, body, url}` under `node_fetch_order`.
2. **`node_validate_order`** reads `{{node_fetch_order.body}}` as `order`, validates it against the schema, and stores `{valid, errors, validated_data}` under `node_validate_order`. If invalid, `success` is `false` and `validated_data` is `null`.
3. **`node_notify`** reads `{{node_validate_order.validated_data}}` as the payload, wraps it with metadata, signs it, and POSTs it — retrying 5xx/timeouts internally up to three times.

To stop the webhook firing when validation fails, insert a [conditional node](/workflow-builder/nodes/conditional) between nodes 2 and 3 that branches on `{{node_validate_order.valid}}`. To pause for a human before a risky delivery, use an [interrupt node](/workflow-builder/nodes/interrupt).

## Running the workflow

You build and run function-node workflows the same way as any other workflow — from the builder, or programmatically. The example below runs a saved workflow over the API and SDKs. Authentication uses `Authorization: Bearer mx_live_…` plus the `X-Organization-ID` header on every request; see [authentication](/api-reference/authentication).

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.modulex.dev/workflows/run" \
    -H "Authorization: Bearer mx_live_xxxxxxxxxxxxxxxx" \
    -H "X-Organization-ID: org_abc123" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id": "wf_123", "input": {"order_id": "ord_42"}}'
  ```

  ```python Python theme={null}
  from modulex import ModuleX

  client = ModuleX(
      api_key="mx_live_xxxxxxxxxxxxxxxx",
      organization_id="org_abc123",
  )

  run = await client.workflows.run(
      "wf_123",
      input={"order_id": "ord_42"},
  )
  ```

  ```javascript JavaScript theme={null}
  import { ModuleX } from "modulex";

  const client = new ModuleX({
    apiKey: "mx_live_xxxxxxxxxxxxxxxx",
    organizationId: "org_abc123",
  });

  const run = await client.workflows.run("wf_123", {
    input: { order_id: "ord_42" },
  });
  ```
</CodeGroup>

The run endpoint sits behind ModuleX's usage gate: when an org is out of credits or over a limit, the run surface returns a flat `DenialEnvelope` (`{code, layer, key, current, limit, reason}`) as **402**, **403**, or **429**, rather than a normal error. See [usage gating & limits](/billing/usage-gating) and [errors & status codes](/api-reference/errors). To stream the function node's results live as the run executes, see [SSE run streaming](/realtime/sse-streaming).

## Related

<CardGroup cols={2}>
  <Card title="Node types overview" icon="diagram-project" href="/workflow-builder/nodes/overview">
    How the function node fits among the nine node kinds and how each writes to run state.
  </Card>

  <Card title="Tool node" icon="wrench" href="/workflow-builder/nodes/tool">
    Call a first-class integration tool instead of a raw HTTP request.
  </Card>

  <Card title="Variables & references" icon="brackets-curly" href="/workflow-builder/variables-and-references">
    The `{{node_id.path}}` reference model used in `input_mapping`.
  </Card>

  <Card title="Conditional node" icon="code-branch" href="/workflow-builder/nodes/conditional">
    Branch on a function node's `success` or `valid` output.
  </Card>
</CardGroup>
