---
url: https://docs.fluxify.rest/getting-started/routing.md
description: >-
  How Fluxify maps incoming requests to your API workflows, validates request
  data, and returns errors to clients.
---

# Routing & Request Validation

When someone calls your API, Fluxify automatically figures out which workflow to run, checks the request data against your defined rules, and only then starts executing your logic. This page explains how that works and what your callers can expect to receive.

## How Requests Are Matched

Every route you create in Fluxify has an HTTP method (like `GET` or `POST`) and a URL path (like `/users/:id`). When a request comes in, Fluxify matches it to the right workflow instantly.

* If a match is found, the workflow runs.
* If nothing matches, the caller gets a `404` response.

Path parameters like `:id` or `:slug` are captured automatically and made available inside your workflow.

::: tip Hot Reloading
Routes update in real time. When you create or change a route in the dashboard, the change takes effect immediately — no server restart needed.
:::

## Experimental CPU-Stall Timeout

Every route has a `timeoutSeconds` setting. It defaults to **30 seconds** and
can only be increased. The setting is carried with the compiled route whenever
you save it, so a timeout change takes effect through the normal route hot
reload path.

The timeout becomes active only when the project's experimental worker-timeout
policy is enabled:

```bash
curl -X PUT "http://localhost:8080/_/admin/api/v1/projects/<project-id>/settings/keys" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token>" \
  -d '{"key":"experimental.workerTimeouts.enabled","value":"true"}'
```

This policy detects synchronous CPU stalls, such as an infinite loop in custom
JavaScript. It does **not** time out a long asynchronous operation while the
execution process remains responsive. When a CPU stall exceeds the configured
route timeout, the in-flight connection is dropped and only the isolated
execution process is replaced; the worker container continues receiving NATS
hot updates.

When the policy is disabled (the default), no execution heartbeats or timeout
tracking are created.

## What Happens Before Your Workflow Runs

Before Fluxify executes a single block in your workflow, it runs through a quick validation pipeline:

1. **Match the route** — find the right workflow for this request.
2. **Check the content type** — make sure the caller sent a format this route accepts.
3. **Parse the request body** — turn it into data your blocks can use.
4. **Validate the body** — check it against your Body Schema (if set).
5. **Validate query parameters** — check URL query strings against your Query Schema (if set).
6. **Validate path parameters** — check URL path values against your Params Schema (if set).
7. **Run your workflow** — only if everything above passed. If the route has [middlewares](../concepts/middlewares.md), the ones set to run **before** it go first, and the ones set to run **after** it get the reply.

If any validation step fails, the workflow never runs and the caller immediately gets a clear error response.

## Accepted Content Types

A route declares which body formats it accepts. New routes accept
**`application/json`** only; choose more under **Accepted content types** when
you create the route, or later in the route's **Settings**.

| Content type | What your workflow receives |
| :--- | :--- |
| `application/json` | The parsed JSON value |
| `application/x-www-form-urlencoded` | An object of field names and text values |
| `multipart/form-data` | An object of text fields plus any uploaded files |
| `application/octet-stream` | The raw binary body as a `Blob`, or a `ReadableStream<Uint8Array>` when the route uses **Pipe request body as a stream** |
| `text/plain` | The text as sent |

For `application/octet-stream`, turn on **Pipe request body as a stream** under
the body settings to hand `getRequestBody()` the live `ReadableStream<Uint8Array>`
instead of collecting the bytes into a `Blob`. Nothing is buffered or
size-limited by Fluxify in that mode, so pipe it straight to a file, S3, or
another destination. Stream routes accept only `application/octet-stream`, have
no body schema, and cannot be called with `x-fluxify-reply: async`.

A request in any other format — or in a format this particular route wasn't set
up for — is rejected with `415` and the workflow never runs. `GET` and `DELETE`
requests carry no body at all.

::: tip Bodies have a size limit
8 MB by default, and larger requests get a `413`. See
[request body size](../deployments/production.md#request-body-size).
:::

## Request Validation

Each route optionally has three schemas you can configure in the Schema Editor:

| Schema | What It Validates |
| :--- | :--- |
| **Body Schema** | The request body (`POST`, `PUT`): JSON, form fields, uploaded files or raw binary |
| **Query Schema** | URL query parameters (e.g. `?page=2&limit=10`) |
| **Params Schema** | URL path values (e.g. the `42` in `/users/42`) |

You only need to configure the ones that matter for your route — unused schemas are simply skipped.

### Available Data Types

| Type | What It Accepts |
| :--- | :--- |
| `String` | Any text, with optional rules like minimum length, maximum length, or a required format |
| `Integer` | A whole number, with optional min/max |
| `Float` | A decimal number, with optional min/max |
| `Boolean` | `true` or `false` |
| `Array` | A list of items, with optional min/max item count |
| `Object` | A structured set of named fields (can be nested) |
| `Enum` | One specific value from a predefined list |
| `File` | One uploaded file from a `multipart/form-data` request, with optional size and file-type rules |
| `Blob` | A raw binary body (`application/octet-stream`), with optional size rules |
| `Use JavaScript` | Custom validation logic you write yourself |

A `String` field can also pick a ready-made **Format**. Any other value is rejected with a clear message, like `Must be a valid UUID v4`:

| Format | Accepts |
| :--- | :--- |
| UUID v4 / UUID v7 | A UUID of that version, e.g. `550e8400-e29b-41d4-a716-446655440000` |
| Email | An email address |
| URL | A URL with any protocol and a host, e.g. `https://a.com`, `tcp://host:9000`, `rediss://host/0` |
| IPv4 / IPv6 address | An IP address of that version |
| ISO 8601 date-time | e.g. `2024-01-01T10:00:00Z` or `2024-01-01T10:00:00+05:30` |

::: tip
Need something these don't cover, like only `https` URLs? Use a regex pattern or `Use JavaScript`.
:::

### Form Fields Are Converted for You

Everything in a form body (`application/x-www-form-urlencoded` or
`multipart/form-data`) arrives as text, because that is how forms are sent.
Fluxify converts form fields to the type your schema declares before checking
them, the same way it does for query and path values:

| Field type | Sent in the form | Your workflow receives |
| :--- | :--- | :--- |
| `Integer` | `age=25` | `25` |
| `Boolean` | `active=false` | `false` |

A JSON body is **not** converted. JSON has real numbers and booleans, so
`{ "age": "25" }` fails an `Integer` field. Send `{ "age": 25 }` instead.

### Accepting Several Files

A `File` field takes exactly one file. To let callers upload more than one
under the same name, make the field an `Array` and set its item type to `File`:

| Field set up as | One file sent | Several files sent |
| :--- | :--- | :--- |
| `File` | Accepted | Rejected with `400` |
| `Array` of `File` | Accepted, as a list of one | Accepted, as a list |

Size and file-type rules on the item apply to **each** file. Minimum and
maximum item counts on the array limit **how many** files can be sent.

::: tip Sending several files
In a multipart request, repeat the same field name once per file. With curl:
`curl -F docs=@a.pdf -F docs=@b.pdf ...`
:::

### Marking Fields as Required

Each field in your schema can be marked as **required** or optional. Required fields must be present — if they're missing, the request is rejected with an error.

### Custom JavaScript Validation

For rules that can't be expressed with the built-in types, you can switch a field to **Use JavaScript** and write your own logic. Your code receives the field's value and should either return `true` (validation passes) or throw a `ValidationError` with a custom message.

```javascript
// Example: reject discount codes that don't start with "SALE"
if (!input.startsWith("SALE")) {
  throw new ValidationError({
    code: "INVALID_CODE",
    message: "Discount codes must start with SALE"
  });
}
return true;
```

Whatever you pass to `ValidationError` is returned directly to the caller — giving you full control over your error messages.

Validation schemas and their trusted JavaScript validators are compiled when a
route artifact loads or hot-reloads. Requests only call the cached validator;
they never compile schema definitions or request-provided `js:` strings. Values
received from a caller are always treated as data, including strings beginning
with `js:`.

## Validation Error Response

When a request fails validation, the caller receives a `400 Bad Request` with a JSON body that clearly describes what went wrong.

```json
{
  "message": "Body validation failed",
  "errors": [
    {
      "path": "address.zipCode",
      "property": "zipCode",
      "errors": ["String must contain at least 5 character(s)"]
    },
    {
      "path": "age",
      "property": "age",
      "errors": ["Expected number, received string"]
    }
  ]
}
```

| Field | Description |
| :--- | :--- |
| `message` | Which part of the request failed: body, query, or path parameters |
| `errors[].path` | The full path to the failing field using dot notation (e.g. `address.zipCode`) |
| `errors[].property` | Just the field name that failed (e.g. `zipCode`) |
| `errors[].errors` | One or more error messages for that field |

When a custom JavaScript validator throws a `ValidationError`, its payload appears verbatim in `errors[].errors` — exactly as you wrote it.

## Error Responses at a Glance

Here's every possible outcome and what the caller receives:

| Situation | Status | Response |
| :--- | :--- | :--- |
| Route doesn't exist | `404` | `{ "message": "Route not found" }` |
| Content type not accepted | `415` | `{ "message": "Unsupported content type ..." }` |
| Body too large | `413` | `{ "message": "Request body exceeds the ...KB limit" }` |
| Body couldn't be read | `400` | `{ "message": "Malformed ... body: ..." }` |
| Validation failed | `400` | `{ "message": "...", "errors": [...] }` |
| Workflow error | `500` | `{ "error": "description" }` |
| Unexpected crash | `500` | `{ "message": "Internal server error" }` |
| Success | `200` | Whatever your Response block returns |

::: info Custom Status Codes
Your workflow can return any HTTP status code you choose using the **Response** block — the defaults above only apply when no status is explicitly set.
:::

## Turning a Route On or Off

A route has an **Enabled** switch for each [environment](../concepts/environments.md#turning-a-route-or-workflow-on-or-off),
in its settings under **Advanced**. A new route is on in **Development** and off
in **Production**. Callers of your public URL get `404` until you turn
**Production** on. A route that is off in an environment returns `404` there.

## Trying a Route in the Playground

The **Playground** sends a real request to your route and shows the response.
Open it with the **Playground** button on the Routes page, or the play button
on the route's canvas.

A **Run in Production** toggle sits at the top. It opens on **Development** and
does not remember your choice, so the next time you open the playground you are
back on Development.

| Toggle | Where the request goes | What it runs with |
| :--- | :--- | :--- |
| **Off** (default, **Development**) | The route's [development address](../concepts/environments.md#calling-a-route-on-development), `/_/dev` + the path | Development worker, development values |
| **On** (**Production**) | The route's public address, exactly as a caller uses it | Production's promoted version, production values |

Development asks for nothing extra: the route must be on in **Development**,
which a new route is, and Fluxify adds the development token for you. If no
development worker is running, the playground says so and cannot send.

Production is offered only once you have
[promoted a version](../concepts/versioning.md#promoting-a-version). Until then
the toggle is disabled and says **Not promoted yet**. The first time you switch
it on, the playground asks you to confirm: **"This calls production: real data,
real side effects."** A production call really reaches your live route and can
change real data.

When production recording is on for the route, a production call shows an
**Open recording** link once the run is recorded, so you can open it on the
**Executions** page. When recording is off, there is nothing to open and the
link does not appear.

For `POST` and `PUT` routes, pick a content type on the **Body** tab. The
editor changes to match it:

| Content type | How you fill in the body |
| :--- | :--- |
| `application/json`, `text/plain` | Type it into the editor |
| Form types | Fill in the fields from your body schema. With no schema, add fields yourself; in `multipart/form-data` each field can hold text or a file |
| `application/octet-stream` | Choose a file, or paste the data as base64 |

You can send a body even when the route has no body schema. Leave
**Validate** on to have the playground check your input against the route's
schemas before sending; turn it off to see how the route itself answers bad input.

## Next Steps

* 🧩 [Explore the Blocks Reference](../blocks/index.md) — build the workflow logic that runs after validation
* 🛡️ [Middlewares](../concepts/middlewares.md) — share checks like API keys or logging across many routes
* ✍️ [Scripting Guide](../scripting/index.md) — learn more about writing custom JavaScript
* ⚙️ [App Config](../concepts/app-config.md) — manage secrets your validators can reference
