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

# Building Custom Workflows

> Use pages as API endpoints — accept POST data, validate it, and store CRM entries from frontend to backend.

Pages accept both `GET` and `POST` requests. Combined with [backend scripts](/backend-scripts/overview), this lets you build data-collection endpoints, custom forms, and mini-apps using regular ElasticFunnels pages — no external server needed.

## The pattern

1. Create a page (e.g. `/weight/checkin`)
2. Add a `<script scope="backend">` that checks for `POST`, validates `request.body` with the [Schema validator](/backend-scripts/validation), and stores the data
3. Your frontend uses `fetch()` to POST JSON data to that page
4. The backend script responds with JSON — the page HTML is skipped when `response.json()` is called

For reusable validation logic across multiple pages, use [shared backend script modules](/backend-scripts/imports) — create a module with exported functions and import them anywhere.

## Full example: Weight check-in

### 1. Create a shared validation module

Create a backend script module (code: `validators`) with shared schemas:

```html theme={null}
<script scope="backend">
  export var WeightCheckinSchema = Schema.object({
    weight:  Schema.number().required().min(50).max(500),
    unit:    Schema.string().valid('lbs', 'kg').default('lbs'),
    notes:   Schema.string().max(500).default(''),
  });

  export function requireCustomer() {
    if (!is_customer) {
      response.status(401);
      response.json({ ok: false, error: 'not authenticated' });
    }
  }
</script>
```

### 2. Create the page `/weight/checkin`

```html theme={null}
<script scope="backend">
  import { WeightCheckinSchema, requireCustomer } from "validators";

  if (request.method === 'POST') {
    requireCustomer();

    var result = WeightCheckinSchema.validate(request.body);

    if (!result.valid) {
      response.status(400);
      response.json({ ok: false, errors: result.errors });
    }

    var data = result.value;

    var entitySlug = 'pattern-tracker'; // your CRM entity slug from settings
    var fields = [
      { fieldKey: 'current_weight', value: data.weight },
      { fieldKey: 'weight_unit', value: data.unit },
      { fieldKey: 'last_checkin', value: new Date().toISOString() }
    ];

    if (data.notes) {
      fields.push({ fieldKey: 'checkin_notes', value: data.notes });
    }

    setCrmFields({ slug: entitySlug, fields: fields });

    response.json({ ok: true, data: data });
  }

  setVariable('page_title', 'Weight Check-in');
</script>

<div>
  <h1>{{ var.page_title }}</h1>
  <form id="checkin-form">
    <input type="number" id="weight" placeholder="Current weight" step="0.1" required />
    <select id="unit">
      <option value="lbs">lbs</option>
      <option value="kg">kg</option>
    </select>
    <textarea id="notes" placeholder="Notes (optional)"></textarea>
    <button type="submit">Log Weight</button>
  </form>
  <div id="result"></div>
</div>

<script>
document.getElementById('checkin-form').addEventListener('submit', async function(e) {
  e.preventDefault();

  var res = await fetch('/weight/checkin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      weight: parseFloat(document.getElementById('weight').value),
      unit: document.getElementById('unit').value,
      notes: document.getElementById('notes').value
    })
  });

  var data = await res.json();
  document.getElementById('result').textContent = data.ok
    ? 'Logged: ' + data.data.weight + ' ' + data.data.unit
    : 'Error: ' + (data.errors || [data.error]).join(', ');
});
</script>
```

### 3. Read the data back on another page

On any page (e.g. `/dashboard`), read the CRM data:

```html theme={null}
<script scope="backend">
  import { requireCustomer } from "validators";
  requireCustomer();

  var entitySlug = 'pattern-tracker';
  var fields = getCrmFields({ slug: entitySlug, fieldKeys: ['current_weight', 'goal_weight'] });
  var weight = fields.current_weight;
  var goal   = fields.goal_weight;
  setVariable('current_weight', weight || 'Not logged yet');
  setVariable('goal_weight', goal || 'Not set');
  setVariable('on_track', weight && goal && Number(weight) <= Number(goal));
</script>

<h2>Current weight: {{ var.current_weight }}</h2>
<h2>Goal: {{ var.goal_weight }}</h2>
<p v-if="var.on_track">You've reached your goal!</p>
```

## How it fits together

```
Frontend (browser)             Backend (sandbox)
─────────────────              ─────────────────
fetch('/weight/checkin', {     → request.method = 'POST'
  method: 'POST',               request.body = { weight: 182, ... }
  body: JSON.stringify(data)
})                             → Schema.validate(request.body)
                              → setCrmFields({ slug: 'pattern-tracker', fields: [...] })
← { ok: true, data: {...} }   ← response.json({ ok: true, data })
```

## CRM functions reference

| Function                                                                              | Description                                                                                                               |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `getCrmEntries({ slug, ... })`                                                        | Fetch entries — **`slug` required** ([Data Functions](/backend-scripts/data-functions#crm-data))                          |
| `getCrmField({ slug, fieldKey, ... })`                                                | Get one field                                                                                                             |
| `getCrmFields({ slug, fieldKeys, ... })`                                              | Get multiple fields at once                                                                                               |
| `setCrmField({ slug, fieldKey, value, ... })`                                         | Set one field                                                                                                             |
| `setCrmFields({ slug, fields, ... })`                                                 | Set multiple fields in one call                                                                                           |
| `moveCrmToStage({ slug, stageId \| stageSlug, ... })`                                 | Move an entry to a specific stage                                                                                         |
| `moveCrmToPipeline({ slug, pipelineId \| pipelineSlug, stageId \| stageSlug?, ... })` | Move an entry into a pipeline                                                                                             |
| `moveCrmToEntity({ fromSlug, toSlug, fieldMap, ... })`                                | Move mapped fields to another CRM entity                                                                                  |
| `moveCrmLead({ slug, pipelineId \| pipelineSlug, stageId \| stageSlug, ... })`        | Direct workflow move (compatibility helper)                                                                               |
| `reassignCrmReference({ slug, fromReferenceType, fromReferenceId, ... })`             | Move entries from a visitor reference to the logged-in customer ([Data Functions — CRM](/backend-scripts/data-functions)) |

### `slug` (CRM entity) vs reference

**`slug`** — required on every call. This is the CRM **entity** slug from entity settings (e.g. `pattern-tracker`). It is **not** the same as `referenceType` (`customer`, `order`, …).

**`referenceType` / `referenceId`** — which record entries attach to. Omit or `null` for **`'customer'`** and the session **`customer_id`**.

```javascript theme={null}
setCrmField({ slug: 'pattern-tracker', fieldKey: 'score', value: 85 });

setCrmField({
  slug: 'pattern-tracker',
  referenceType: 'order',
  referenceId: '12345',
  fieldKey: 'followup',
  value: 'done',
});
```

Calls must use a **single options object** (no positional lists).

### Parameter types

| Property                     | Allowed types / notes                                                                |
| ---------------------------- | ------------------------------------------------------------------------------------ |
| `slug`                       | entity slug string — **required**; must exist for the brand                          |
| `referenceType`              | string (alphanumeric, `-`, `_`) or omit / `null`                                     |
| `referenceId`                | string (max 512 chars) or omit / `null`                                              |
| `fieldKey`                   | string (alphanumeric, `_`, `.`, `-`) — **required**                                  |
| `value`                      | string, number, boolean, or JSON object — **required** for set (`0` / `false` valid) |
| `pipelineId` / `stageId`     | integer id for workflow move targets                                                 |
| `pipelineSlug` / `stageSlug` | slug string for workflow move targets                                                |

### Moving through CRM workflow

`moveCrmToStage` and `moveCrmToPipeline` are the workflow transition functions. You can move:

* Stage to stage in the same pipeline
* Pipeline to pipeline (by setting a stage that belongs to the new pipeline)

```javascript theme={null}
moveCrmToStage({
  slug: 'pattern-tracker',
  stageSlug: 'qualified'
});

moveCrmToPipeline({
  slug: 'pattern-tracker',
  pipelineSlug: 'sales',
  stageSlug: 'proposal-sent'
});

moveCrmToPipeline({
  slug: 'pattern-tracker',
  pipelineSlug: 'sales',
  stageSlug: 'proposal-sent',
  async: true
});
```

These only change workflow placement (`pipeline_id`, `stage_id`). They do not map one field key into another.
For field mapping (for example `quiz_email -> email`), do it explicitly with reads/writes:

```javascript theme={null}
var quizEmail = getCrmField({ slug: 'pattern-tracker', fieldKey: 'quiz_email' });
if (quizEmail) {
  setCrmField({ slug: 'pattern-tracker', fieldKey: 'email', value: quizEmail, force: true });
}
```

Or move across entities in one call:

```javascript theme={null}
moveCrmToEntity({
  fromSlug: 'quiz-tracker',
  toSlug: 'sales-leads',
  fieldMap: {
    quiz_email: 'email',
    quiz_score: 'lead_score'
  },
  pipelineSlug: 'sales',
  stageSlug: 'new',
  removeFromOld: true
});

moveCrmToEntity({
  fromSlug: 'quiz-tracker',
  toSlug: 'sales-leads',
  fieldMap: { quiz_email: 'email' },
  async: true
});
```

### Input validation

All inputs are strictly validated before data is stored:

* **`slug`** must match a CRM entity configured for your brand; unknown slug → **`TypeError`**
* **`fieldKey`** must match `/^[a-zA-Z0-9_.-]+$/`
* **`referenceType`** must match `/^[a-zA-Z0-9_-]+$/`
* **`referenceId`** rejects `{}[]"\` characters
* **String values** are capped at 10,000 characters
* **Numbers** must be finite (no `NaN`, `Infinity`)
* **Objects** are JSON-serialized and capped at 10 KB
* Missing **`slug`**, **`fieldKey`**, or **`value`** (set) throws **`TypeError`**
* Other invalid inputs still cause `false` / `null` / `[]` where applicable

## Quiz / anonymous visitor → customer CRM migration

A common pattern: save a quiz or funnel payload with `setCrmField({ referenceType: 'quiz_visitor', referenceId: sessionUuid, ... })` while the visitor is anonymous, then **after members login** call `reassignCrmReference` so the same CRM entries attach to `customer` + `customer_id`. `fromReferenceId` must match `session.get('quiz_visitor_id')` and the target must be the current session customer — see **ReassignCrmReference** on the [Data Functions](/backend-scripts/data-functions) page. Clear `quiz_visitor_id` from the session after a successful migration to keep the flow idempotent.

## Security checklist

<Warning>
  **Always validate `request.body` in your backend script.** The body comes from the visitor's browser and can contain anything.
</Warning>

1. **Validate with Schema** — use the built-in [Schema validator](/backend-scripts/validation) to enforce types, lengths, and allowed values
2. **Check authentication** — verify `is_customer` before storing data
3. **Use `response.json()`** — this stops page rendering and returns JSON directly
4. **Use `response.status()`** — set proper HTTP status codes (400 for validation errors, 401 for auth)
5. **Share validation logic** — create a [backend script module](/backend-scripts/imports) for reusable schemas and auth checks

## POST body limits

| Constraint        | Value                                    |
| ----------------- | ---------------------------------------- |
| Max body size     | 64 KB (larger payloads are dropped)      |
| Content type      | `application/json` only                  |
| Body available as | `request.body` (parsed object or `null`) |

## Related

* [Backend Scripts Overview](/backend-scripts/overview) — how backend scripts work, sandboxing, execution order
* [Schema Validator](/backend-scripts/validation) — full reference for the `Schema` validation API
* [Shared Modules](/backend-scripts/imports) — import/export reusable code between pages
* [Request Context](/backend-scripts/context) — `request.body`, `request.method`, and other globals
* [CRM Overview](/crm/overview) — entities, fields, pipelines, and the CRM data model
