> ## 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.

# Data Functions

> Query customers, orders, products, courses, blog, and more from your backend scripts.

Backend scripts expose the same data functions available in the template engine, so you can query your brand data and use the results for logic, not just display.

All data functions that hit the database are **asynchronous** under the hood, but they look synchronous in your code — just call them and use the return value directly. Pure-computation helpers (like `formatPrice`, `productSavings`) are truly synchronous.

## Customer

### `getCustomer()`

Returns the full current customer object from the session (same as the `customer` global).

```javascript theme={null}
var c = getCustomer();
if (c && c.email) {
  console.log('Customer:', c.email);
}
```

### `getUser()`

Returns a lightweight `{ name, email }` object for the current session customer.

```javascript theme={null}
var user = getUser();
if (user.email) {
  setVariable('greeting', 'Welcome, ' + user.name);
}
```

## Translations

Backend scripts use the same **resolved locale** as page templates (`req.locale` after host resolution). That value comes from route/query/session/domain/brand/`Accept-Language` and is then **snapped** to a row in Advanced → Translations when the brand has languages configured. See [Internationalization](/pages/internationalization) for precedence and template helpers (`t()`, `| t`, and related).

Most translation helpers are **async** in the sandbox — use `await`.

### `getLocale()`

Returns the active resolved locale code for this request (same as `req.locale`), e.g. `'en'` or `'ro'`.

```javascript theme={null}
if ((await getLocale()) === 'ro') {
  setVariable('currency_symbol', 'lei');
}
```

### `getLocales()`

Returns the list of locale **codes** configured for the brand (admin sort order). Empty array when the brand has no translation languages.

```javascript theme={null}
var available = await getLocales(); // ['en', 'ro']
```

### `getDefaultLocale()`

Returns the brand **default** language code, or the first language by sort order, or `''` when none exist.

```javascript theme={null}
if (await getDefaultLocale() === 'en') {
  setVariable('show_usd', true);
}
```

### `t(key, params?)`

**Async.** Tries the **brand** dictionary first for `key`, then built-in locale JSON. Optional `params` is a plain object for `{{ name }}`-style interpolation in **built-in** strings — not a fallback text argument. This is the default choice in backend scripts for the same keys you use with **`t()`** or **`| t`** in templates.

```javascript theme={null}
var label = await t('title.greeting');
var hero = await t('hero.title');
var cta = await t('checkout.cta');
setVariable('hero_title', hero);
setVariable('checkout_cta', cta);
var pct = await t('product.savings_percent', { percent: 20 });
setVariable('savings_label', pct);
```

### `getTranslation(key)`

**Async.** Reads **only** the brand dictionary (no built-in fallback); returns `''` if the key is missing. Prefer **`t()`** unless you explicitly need that behavior.

### `getTranslations()`

Returns a **plain object** copy of every `key → value` for the active resolved language (empty object when the brand has no entries).

```javascript theme={null}
var all = await getTranslations();
console.log(Object.keys(all).length);
```

### `getResolvedLanguage()`

Returns the **snapped** language code used for brand translations (e.g. `'ro'`), or the raw locale hint when the brand has no translation languages configured.

```javascript theme={null}
var code = await getResolvedLanguage();
if (code === 'de') {
  setVariable('region', 'EU');
}
```

## Orders

### `getOrders(sortBy?, limit?)`

Fetch the current customer's orders from the database.

| Parameter | Type   | Default    | Description                     |
| --------- | ------ | ---------- | ------------------------------- |
| `sortBy`  | string | `'newest'` | `'newest'` or `'oldest'`        |
| `limit`   | number | `50`       | Max orders to return (max 1000) |

```javascript theme={null}
var orders = getOrders('newest', 10);

if (orders.length > 0) {
  setVariable('has_orders', true);
  setVariable('latest_order', orders[0].order_number);

  console.log('Found', orders.length, 'orders');
}
```

Each order object contains:

```javascript theme={null}
{
  order_number: 'ORD-12345',
  created_at: '2025-01-15T10:30:00Z',
  billing_address: '...',
  billing_city: '...',
  billing_state: '...',
  billing_zip: '...',
  billing_country: 'US',
  shipping_address: '...',
  shipping_city: '...',
  shipping_state: '...',
  shipping_zip: '...',
  shipping_country: 'US',
  currency: 'USD',
  customer_name: 'John Doe',
  customer_email: 'john@example.com',
  products: [
    { name: 'Premium Plan', price: 49.99, quantity: 1, image_url: '...', currency_code: 'USD' }
  ],
  tracking: [
    { tracking_number: '1Z999AA10123456784', tracking_url: '...', carrier: 'UPS' }
  ]
}
```

### `getOrder(code)`

Fetch **one** order for the current customer. Same scope as `getOrders`. The `code` argument is required: public order ID (`order_number`), internal `order_id`, or conversion `code`. Returns `null` if not found.

```javascript theme={null}
var order = getOrder(request.query.order);
if (order) {
  setVariable('order', order);
}
```

See [Showing order details](/members-area/order-details) for template examples.

### `getOrderFulfillment(code)`

Returns an **allowlisted** fulfillment **summary** for a single order. Same `code` rules as `getOrder`. Use this for status labels and timestamps shown to customers.

Returns `null` when the order exists but has **no** fulfillment record yet (no integration / not scheduled), or when the order is not found.

**Returned fields** (only these; raw `fulfillment_provider_response` and `fulfillment_order_data` are never exposed):

| Field               | Type           | Description                                                                                |
| ------------------- | -------------- | ------------------------------------------------------------------------------------------ |
| `status`            | string \| null | Normalized: `pending`, `scheduled`, `sent`, `failed`, `skipped`, `cancelled`, or `unknown` |
| `provider_order_id` | string \| null | External reference from the fulfillment provider when stored on the conversion             |
| `integration_name`  | string \| null | Display name of the fulfillment integration                                                |
| `scheduled_at`      | string \| null | ISO timestamp when processing was scheduled                                                |
| `sent_at`           | string \| null | ISO timestamp when the order was sent to the provider                                      |
| `failed_at`         | string \| null | ISO timestamp on failure                                                                   |
| `cancelled_at`      | string \| null | ISO timestamp if cancelled                                                                 |
| `error_message`     | string \| null | Truncated message safe for display (max 200 chars)                                         |

```javascript theme={null}
var f = getOrderFulfillment(request.query.order);
if (f) {
  setVariable('fulfillment', f);
}
```

### `getOrderFulfillments()`

Takes **no arguments**. Returns **all** tracking-style rows for the **current customer** across their orders (up to the same order cap as `getOrders` with limit 1000, newest first). Each row includes `order_number` so you can link to an order detail page.

Duplicate `(order_number, tracking_number)` pairs are removed.

**Returned fields per row:**

| Field             | Type           | Description                                 |
| ----------------- | -------------- | ------------------------------------------- |
| `order_number`    | string         | Public order id for this shipment           |
| `order_date`      | string \| null | Order `created_at` (ISO)                    |
| `tracking_number` | string         | Tracking number                             |
| `tracking_url`    | string         | Tracking link (e.g. 17track when generated) |
| `carrier`         | string         | Carrier name                                |

```javascript theme={null}
var shipments = getOrderFulfillments();
setVariable('shipments', shipments);
```

<Tip>
  These APIs return **filtered** data only. Full provider API responses and internal fulfillment payloads are not available in backend scripts.
</Tip>

## Bonus Products

### `getBonusProducts()`

Fetch the current customer's bonus products — digital downloads and extras they received as part of a purchase. Returns a deduplicated list of bonus items from all the customer's orders.

```javascript theme={null}
var bonuses = getBonusProducts();

if (bonuses.length > 0) {
  setVariable('bonuses', bonuses);
  setVariable('has_bonuses', true);
}
```

Each bonus object contains:

```javascript theme={null}
{
  id: 123,                        // product ID (from brand_products)
  code: 'bonus-ebook',            // product code
  title: 'Free Bonus eBook',      // product title
  description: 'A bonus guide…',  // product description
  image_url: 'https://…/img.jpg', // product image
  bonus_url: 'https://…/file',    // download URL
  price: 0,                       // price (typically 0 for bonuses)
  type: 'digital',                // product type
  classification: 'bonus'         // product classification
}
```

`bonus_url` is the public download URL exposed to backend scripts. Internally, it comes from the saved conversion line when available, and otherwise falls back to the current bonus product download URL from the catalog.

Bonus products are identified by conversion line items where `is_bonus = true`. The function enriches each item with full product details from the catalog. If the same bonus was granted across multiple purchases, it appears only once.

Scoped to the **current session** (email and/or session id): if the visitor is not identified, the list is empty.

### `getAllBonusProducts()`

Returns **all** bonus-classified products in the brand **catalog** (`classification: bonus`). This is **not** limited to what the current customer earned; it does not check orders or conversions.

Object shape matches `getBonusProducts()` (including `title`, `bonus_url`, etc.). For catalog-only rows, `bonus_url` is the product's stored download URL.

```javascript theme={null}
var catalogBonuses = getAllBonusProducts();
setVariable('all_bonus_skus', catalogBonuses);
```

<Tip>
  For the same filter but the **full product** shape (price, currency, `merchant_product_ids`, the normalized `product_files` array, etc.), use `getProducts({ classification: 'bonus' })` instead.
</Tip>

### `getPurchasedProducts(sortBy?, limit?)`

Customer-scoped helper for **member-area "My Library" pages**. Returns every product the current customer has purchased, fully catalog-enriched, with **same-order bonuses already attached** under `included_bonuses[]` and a pre-rolled-up `downloads[]` array (each entry tagged with `kind`).

This replaces the manual recipe of:

* calling `getSessionOrders` + `getAllProducts` + `getBonusProducts` separately,
* joining order lines back to the catalog by code,
* guessing which bonuses go with which product by matching strings in titles.

| Parameter | Type   | Default    | Description                                                                                               |
| --------- | ------ | ---------- | --------------------------------------------------------------------------------------------------------- |
| `sortBy`  | string | `'newest'` | `'newest'` or `'oldest'` — controls which purchase wins when the same product appears in multiple orders. |
| `limit`   | number | `50`       | Max orders considered when scanning the customer's history (max 1000).                                    |

```javascript theme={null}
var purchasedProducts = getPurchasedProducts('newest');
setVariable('purchased_products', purchasedProducts);
```

Each purchased product contains:

```javascript theme={null}
{
  id: 100,
  code: 'theta-clear',
  title: 'Theta Clear',           // checkout_title preferred when present
  checkout_title: '',
  description: '...',
  image: 'https://cdn/theta.png',
  type: 'digital',
  classification: 'main',
  price: 39,                       // catalog price
  retail_price: 99,
  currency: 'USD',

  // Decorated catalog fields (same shape as getProducts)
  product_files: [ /* normalized file rows with `kind` */ ],
  digital_files: [ /* product_files filtered by type='digital' */ ],
  bonus_files:   [ /* product_files filtered by type='bonus' */ ],
  bonus_file: 'https://cdn/...',
  digital_file: 'https://cdn/...',
  primary_download_url: 'https://cdn/...',

  // Pre-rolled-up downloads — one flat list, deduped by URL, each tagged
  // with kind ('audio' | 'video' | 'document' | 'image' | 'archive' | 'file').
  // Use this instead of walking product_files / digital_files / bonus_files
  // by hand. See the productDownloads helper section above.
  downloads: [
    { url: '...', title: 'Theta Clear Vocal Guidance', kind: 'audio',
      purpose: 'digital', extension: 'mp3', mime_type: 'audio/mpeg',
      size: null, is_primary: true, sort_order: 0 },
  ],

  // Purchase context (flat — matches the fields used in member-area templates)
  order_number: 'A0FZ2V0A',
  conversion_code: 'CONV-1',
  created_at: '2026-04-28T10:00:00Z',
  line_price: 39,                  // amount paid for this line
  line_currency: 'USD',
  quantity: 1,
  has_shipping: false,             // true if the order has a shipping address

  // Bonuses received in the SAME order — every is_bonus line, no string
  // filtering. Each entry is catalog-enriched and carries its own
  // `downloads[]` rollup plus a direct `bonus_url`.
  included_bonuses: [
    {
      id: 200,
      code: 'integration-journal',
      title: 'The Theta Clear Integration Journal',
      classification: 'bonus',
      bonus_url: 'https://cdn/journal.pdf',
      downloads: [ /* same shape as above */ ],
      // ...full catalog fields
    },
    // ... all other bonus lines from the same order
  ]
}
```

**Bonus attribution rule:** every `is_bonus` line in an order is attached to every main line of that same order. This matches how funnels actually deliver bonuses (the bonuses come with the offer, not with one specific catalog item). It also avoids the silent-drop bug of older recipes that filtered bonuses by title keywords.

**Dedup rule:** one entry per product (keyed by id, then code). When the same product appears in multiple orders, the order matching the `sortBy` direction wins (newest purchase for `'newest'`). Bonuses come from the kept order.

```javascript theme={null}
// Member-area library — render every purchased product with its bonuses
// and downloads already prepared. No string heuristics, no manual joins.
var library = getPurchasedProducts('newest');

setVariable('library', library);
setVariable('library_count', library.length);
setVariable('has_library', library.length > 0);
```

Scoped to the **current session**: returns `[]` when the visitor is not identified.

## Subscriptions

Customer-scoped recurring billing functions. All operations are restricted to subscriptions belonging to the **current session customer** (matched by email) — a script can never read or modify another customer's subscription.

Write functions return `{ ok: boolean, error?: string, ...rest }` and **never throw** on validation failure. They enforce status transitions automatically (e.g. only `active` can be paused or skipped, only `paused` can be resumed, already-canceled cannot be canceled again).

For full request/response shapes, content-gating examples, and ready-to-use page-based API examples, see [Subscriptions (backend template engine)](/backend-template-engine/subscriptions).

<Note>
  **Per-request cache:** All read helpers (`getSubscriptions`, `getSubscription` with an object arg, `hasSubscription`, `countSubscriptions`) share a per-request cache. The database is queried only once per request — subsequent calls with different filters read from memory.
</Note>

### `getSubscriptions(status?, limit?)`

List the current customer's subscriptions, newest first.

| Parameter | Type   | Default | Description                                                                                                 |
| --------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------- |
| `status`  | string | —       | Optional filter: `active`, `paused`, `canceled`, `past_due`, or `trial`. Empty / unknown values return all. |
| `limit`   | number | `50`    | Max subscriptions to return (1–200).                                                                        |

```javascript theme={null}
var all = getSubscriptions();
var active = getSubscriptions('active', 25);

setVariable('subscriptions', all);
setVariable('has_active_subscription', active.length > 0);
```

Each subscription object contains:

```javascript theme={null}
{
  id: 'sub_abc123',
  status: 'active',                      // active | paused | canceled | past_due | trial
  customer_email: 'jane@example.com',
  customer_name: 'Jane Doe',
  products: [
    { code: 'monthly-plan', name: 'Monthly Plan', price: 29.99, quantity: 1, type: 'main' }
  ],
  amount: 29.99,
  currency_code: 'USD',
  tax_amount: 0,
  shipping_amount: 0,
  discount_amount: 0,
  frequency: 1,
  frequency_unit: 'month',               // day | week | month | year
  next_charge_at: '2026-05-12T00:00:00Z',
  started_at: '2026-04-12T00:00:00Z',
  paused_at: null,
  resume_at: null,
  canceled_at: null,
  cancel_reason: null,
  cycle_number: 2,
  trial_days: 0,
  first_charge_free: false,
  original_transaction_id: 'txn_xyz789',
  gateway: 'stripe',
  card_last4: '4242',
  card_brand: 'visa',
  created_at: '2026-04-12T00:00:00Z',
  updated_at: '2026-05-01T00:00:00Z'
}
```

### `getSubscription(arg)`

Fetch a single subscription. Accepts either a **transaction ID string** or a **filter object**.

**String form** — fetch by original transaction ID (existing behavior):

```javascript theme={null}
var sub = getSubscription(request.query.id);
if (sub) {
  setVariable('subscription', sub);
}
```

**Object form** — fetch the first matching subscription from the per-request cache:

| Property | Type   | Default    | Description                                                    |
| -------- | ------ | ---------- | -------------------------------------------------------------- |
| `code`   | string | —          | Filter by product code in the subscription's `products` array. |
| `status` | string | `'active'` | Filter by status. Use `'any'` to skip status filtering.        |

```javascript theme={null}
// First active subscription for a specific product
var sub = getSubscription({ code: 'monthly-plan' });

// Most recent canceled subscription (win-back flow)
var canceled = getSubscription({ code: 'monthly-plan', status: 'canceled' });
if (canceled) {
  setVariable('canceled_at', canceled.canceled_at);
  setVariable('show_winback', true);
}

// First subscription regardless of code, any status
var any = getSubscription({ status: 'any' });
```

### `hasSubscription(filter?)`

Returns `true` if the customer has at least one subscription matching the filter, `false` otherwise. All fields are optional.

| Property | Type   | Default    | Description                                                                                                                                                                        |
| -------- | ------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`   | string | —          | Match by product code.                                                                                                                                                             |
| `status` | string | `'active'` | Match by status. Use `'any'` to skip.                                                                                                                                              |
| `date`   | string | —          | ISO date string. When set, ignores `status` and instead checks whether the subscription was active on that date (`started_at ≤ date` and `canceled_at` is null or after the date). |

```javascript theme={null}
// Does the customer have any active subscription?
var isSubscribed = hasSubscription();

// Active subscription for a specific product (content gate)
if (hasSubscription({ code: 'premium-plan' })) {
  setVariable('show_premium', true);
}

// Was the customer subscribed on a specific date? (date-based gate)
if (hasSubscription({ code: 'premium-plan', date: '2026-01-01' })) {
  setVariable('can_view_jan_content', true);
}

// Does the customer have a past-due subscription? (dunning banner)
if (hasSubscription({ status: 'past_due' })) {
  setVariable('show_billing_warning', true);
}
```

### `countSubscriptions(filter?)`

Returns the number of subscriptions matching the filter. Same filter shape as `hasSubscription`, excluding `date`.

| Property | Type   | Default    | Description                                     |
| -------- | ------ | ---------- | ----------------------------------------------- |
| `code`   | string | —          | Filter by product code.                         |
| `status` | string | `'active'` | Filter by status. Use `'any'` for all statuses. |

```javascript theme={null}
var activeCount = countSubscriptions();
setVariable('active_sub_count', activeCount);

var totalCount = countSubscriptions({ status: 'any' });
var canceledCount = countSubscriptions({ status: 'canceled' });
```

### `cancelSubscription(transactionId, reason?)`

Cancel an active or paused subscription. Fires the `subscription_cancel` event.

```javascript theme={null}
var result = cancelSubscription('txn_xyz789', 'Too expensive');
if (!result.ok) {
  setVariable('error_message', result.error);
}
```

### `pauseSubscription(transactionId, resumeAt?)`

Pause an `active` subscription. Optional `resumeAt` is an ISO date string for an automatic resume — invalid dates return `{ ok: false, error: 'Invalid resume_at date' }`.

```javascript theme={null}
pauseSubscription('txn_xyz789');
pauseSubscription('txn_xyz789', '2026-08-01T00:00:00Z');
```

### `resumeSubscription(transactionId)`

Resume a `paused` subscription. If the stored `next_charge_at` is in the past, it is automatically advanced based on the subscription's frequency.

```javascript theme={null}
resumeSubscription('txn_xyz789');
```

### `skipSubscription(transactionId)`

Skip the next billing cycle on an `active` subscription. Returns the new next charge date on success.

```javascript theme={null}
var result = skipSubscription('txn_xyz789');
if (result.ok) {
  setVariable('next_charge', result.next_charge_at);
}
```

### `changeSubscriptionFrequency(transactionId, interval, intervalCount)`

Change the billing cadence on an `active` or `paused` subscription. `interval` must be one of `day`, `week`, `month`, `year`. `intervalCount` must be a positive integer.

```javascript theme={null}
var result = changeSubscriptionFrequency('txn_xyz789', 'month', 2);
// → { ok: true, frequency_unit: 'month', frequency: 2, next_charge_at: '2026-06-12T...' }
```

## Conversions

### `getConversions(email)`

Look up conversions (purchases) by email address.

```javascript theme={null}
var conversions = getConversions('john@example.com');

if (conversions.length > 0) {
  setVariable('returning_customer', true);
}
```

Returns an array of conversion records with fields like `public_order_id`, `created_at`, `customer_email`, `product_name`, `total`, `currency_code`, `status`.

## Products

### `getProduct(code)`

Fetch a single product by its product code.

```javascript theme={null}
var product = getProduct('premium-plan');

if (product) {
  setVariable('product_name', product.name);
  setVariable('product_price', product.price);
}
```

### `getProducts(filters?)`

Fetch products with optional filters.

```javascript theme={null}
// All products
var all = getProducts();

// Filtered
var physical = getProducts({ type: 'physical' });
var subscriptions = getProducts({ classification: 'subscription' });
var specific = getProducts({ codes: 'plan-a,plan-b,plan-c' });
```

| Filter           | Type   | Description                                |
| ---------------- | ------ | ------------------------------------------ |
| `type`           | string | `'physical'`, `'digital'`, or `'any'`      |
| `classification` | string | `'subscription'`, `'one-time'`, or `'any'` |
| `codes`          | string | Comma-separated product codes              |

### `getAllProducts()`

Fetch all products for the brand (no filtering).

```javascript theme={null}
var products = getAllProducts();
console.log('Total products:', products.length);
```

### `getProductByCode(merchantCode, productCode)`

Look up a product by merchant gateway code and the merchant-specific product code.

```javascript theme={null}
var product = getProductByCode('stripe', 'price_1234');
```

### Product files and download URLs

Catalog products can include attached downloads. **`getProduct`**, **`getAllProducts`**, **`getProductByCode`**, and **`getProducts`** (with **or without** filters) all return the same normalized shape, so you can rely on the same fields in every code path.

Use these fields:

| Field                      | Type   | Description                                                                                                                                                                                                                                                                                             |
| -------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`product_files`**        | array  | All file rows attached to the product (always an array — never a JSON string). Each item: `{ id, purpose, title, description, image, file, sort_order, is_primary, metadata }`. `purpose` is `'digital'` or `'bonus'` (defaults to `'digital'` when missing). Rows with no `file` URL are filtered out. |
| **`digital_files`**        | array  | Subset of `product_files` where `purpose === 'digital'`. Same row shape.                                                                                                                                                                                                                                |
| **`bonus_files`**          | array  | Subset of `product_files` where `purpose === 'bonus'`. Same row shape.                                                                                                                                                                                                                                  |
| **`digital_file`**         | string | URL of the **primary** digital file (the `is_primary` row with the lowest `sort_order` whose `purpose` is `'digital'`). Falls back to the legacy `digital_file` column on the product if set. Empty string when none.                                                                                   |
| **`bonus_file`**           | string | URL of the **primary** bonus file (the `is_primary` row with the lowest `sort_order` whose `purpose` is `'bonus'`). Falls back to the legacy `bonus_file` column, then to any primary file. Empty string when none.                                                                                     |
| **`primary_download_url`** | string | URL of the overall primary file across all purposes (`is_primary DESC`, then `sort_order ASC`). Empty string when none.                                                                                                                                                                                 |

Inside each `product_files` row:

| Field         | Type           | Description                                                                                                                                                                                                                |
| ------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | number \| null | Internal file id.                                                                                                                                                                                                          |
| `purpose`     | string         | `'digital'` or `'bonus'`.                                                                                                                                                                                                  |
| `title`       | string         | Display title.                                                                                                                                                                                                             |
| `description` | string         | Optional description.                                                                                                                                                                                                      |
| `image`       | string         | Optional preview image URL.                                                                                                                                                                                                |
| `file`        | string         | Download URL. Always present (rows without a URL are dropped).                                                                                                                                                             |
| `kind`        | string         | Coarse classification derived from `metadata.extension` and `metadata.mime_type`. One of `'audio'`, `'video'`, `'document'`, `'image'`, `'archive'`, `'file'`. Use this to branch on file type without re-parsing the URL. |
| `sort_order`  | number         | Display / primary-selection order (defaults to position in the array if not stored).                                                                                                                                       |
| `is_primary`  | boolean        | Whether this row is the primary file for its purpose.                                                                                                                                                                      |
| `metadata`    | object         | Public metadata object (always an object, parsed from JSON when stored that way). See **Metadata fields** below. Internal storage pointers are intentionally not exposed.                                                  |

#### Metadata fields

The `metadata` object on each `product_files` row only exposes the following fields:

| Field       | Type           | Description                                                                                                                                                                                                                 |
| ----------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mime_type` | string \| null | MIME type of the file (e.g. `'audio/mpeg'`, `'application/pdf'`).                                                                                                                                                           |
| `size`      | number \| null | Size in bytes.                                                                                                                                                                                                              |
| `checksum`  | string \| null | Optional checksum, when one was recorded at upload.                                                                                                                                                                         |
| `extension` | string         | Lowercase extension without the dot (e.g. `'mp3'`, `'pdf'`, `'docx'`). Derived from the `file` URL when possible, with a small mime-type fallback for opaque/signed URLs. Empty string when neither source can produce one. |

<Tip>
  Internal storage pointers (`provider`, `storage_path`, `brand_file_id`) used to be present on this object and are now intentionally hidden — they were only useful to the platform itself and should not be relied on by scripts or templates.
</Tip>

**Example** — pick the primary bonus URL with a safe fallback, then list all digital files with their type:

```javascript theme={null}
var p = getProduct('my-sku');

if (p && (p.bonus_file || p.primary_download_url)) {
  setVariable('bonus_href', p.bonus_file || p.primary_download_url);
}

if (p && p.digital_files && p.digital_files.length) {
  for (var i = 0; i < p.digital_files.length; i++) {
    var row = p.digital_files[i];
    console.log(
      row.title,
      row.file,
      'kind:', row.kind,
      'ext:', row.metadata && row.metadata.extension,
      'mime:', row.metadata && row.metadata.mime_type
    );
  }
}
```

**Example** — list every bonus across a filtered product set:

```javascript theme={null}
var bonuses = getProducts({ classification: 'bonus' });

for (var i = 0; i < bonuses.length; i++) {
  var b = bonuses[i];
  console.log(b.title, '→', b.bonus_file || b.primary_download_url);
}
```

For the same field names in **backend** page templates (`GetProduct` / `GetProducts` / `GetAllProducts`), see [Products, categories & shipping (backend)](/backend-template-engine/products-and-shipping).

### `productDownloads(product)`

Roll up everything a member-area UI normally needs to render "what can the customer actually download for this product" into a single flat array. Replaces the recurring `extractFiles` / "classify by extension" gymnastics — every entry already carries `kind`, so you can `switch` on it without parsing URLs.

Source order, deduplicated by URL (first occurrence wins):

1. `digital_files` (purpose=`'digital'`)
2. `bonus_files` (purpose=`'bonus'`)
3. anything left in `product_files` not already picked up
4. legacy scalar `digital_file` / `bonus_file` columns — surfaced **only when no file rows exist**, so you never get duplicates of a row that's already in `product_files`

Each entry:

| Field        | Type           | Description                                                                  |
| ------------ | -------------- | ---------------------------------------------------------------------------- |
| `url`        | string         | Download URL (always present).                                               |
| `title`      | string         | Display title. Falls back to `'Download'`.                                   |
| `kind`       | string         | One of `'audio'`, `'video'`, `'document'`, `'image'`, `'archive'`, `'file'`. |
| `purpose`    | string         | `'digital'` or `'bonus'`.                                                    |
| `extension`  | string         | Lowercase extension without the dot (e.g. `'mp3'`).                          |
| `mime_type`  | string \| null | MIME type when known.                                                        |
| `size`       | number \| null | Size in bytes when known.                                                    |
| `is_primary` | boolean        | Whether this row is the primary file for its purpose.                        |
| `sort_order` | number         | Display order.                                                               |

**Example** — render a member's library entry without any classify-by-URL logic:

```javascript theme={null}
var p = getProduct('my-sku');
var files = productDownloads(p);

for (var i = 0; i < files.length; i++) {
  var f = files[i];
  switch (f.kind) {
    case 'audio':    console.log('🎧', f.title, f.url); break;
    case 'video':    console.log('🎬', f.title, f.url); break;
    case 'document': console.log('📄', f.title, f.url); break;
    case 'image':    console.log('🖼️', f.title, f.url); break;
    case 'archive':  console.log('📦', f.title, f.url); break;
    default:         console.log('📁', f.title, f.url);
  }
}
```

The same helper is available in backend page templates as `ProductDownloads(product)` — see [Products, categories & shipping (backend)](/backend-template-engine/products-and-shipping).

### `productSavings(product)`

Compute savings info from a product's `price` and `retail_price`. Returns an object:

```javascript theme={null}
var product = getProduct('premium-plan');
var savings = productSavings(product);

// savings = {
//   amount: 20,
//   percent: 25,
//   amountFormatted: '$20.00',
//   percentFormatted: '25%'
// }

if (savings.percent > 0) {
  setVariable('discount_badge', 'Save ' + savings.percentFormatted);
}
```

Returns `{ amount: 0, percent: 0, amountFormatted: '', percentFormatted: '' }` when there is no discount.

### `subscriptionSummary(product)`

Returns a human-readable subscription description string.

```javascript theme={null}
var product = getProduct('monthly-plan');
var label = subscriptionSummary(product);
// e.g. "7-day free trial · Every 1 month"

setVariable('subscription_label', label);
```

Returns an empty string for non-subscription products.

### `formatPrice(value, currency?)`

Format a numeric price with the given currency code.

```javascript theme={null}
var formatted = formatPrice(49.99, 'USD');
// "$49.99"
setVariable('display_price', formatted);
```

## Courses

### `getCourse(courseId)`

Fetch a course by ID, including modules and contents.

```javascript theme={null}
var course = getCourse(42);
if (course) {
  setVariable('course_title', course.title);
  setVariable('lesson_count', course.total_lesson_count);
}
```

### `getCourseBySlug(slug)`

Fetch a course by its URL slug.

```javascript theme={null}
var course = getCourseBySlug('javascript-basics');
```

### `getModuleBySlug(courseSlug, moduleSlug)`

Fetch a single module with its contents.

```javascript theme={null}
var module = getModuleBySlug('javascript-basics', 'variables');
if (module) {
  setVariable('module_title', module.title);
}
```

### `getCourses()`

Fetch all courses for the brand with lesson and module counts.

```javascript theme={null}
var courses = getCourses();
setVariable('course_count', courses.length);
```

### `getCoursesByCategorySlug(categorySlug)`

Filter courses by category slug.

```javascript theme={null}
var marketingCourses = getCoursesByCategorySlug('marketing');
setVariable('marketing_course_count', marketingCourses.length);
```

### `getCategoriesWithCourses()`

Get all course categories with their courses grouped together.

```javascript theme={null}
var categories = getCategoriesWithCourses();
// [
//   { category_slug: 'marketing', category_name: 'Marketing', courses: [...] },
//   { category_slug: 'design', category_name: 'Design', courses: [...] },
// ]

setVariable('category_count', categories.length);
```

### `getCourseForCustomer(slug)`

Fetch a single course with enrollment data for the current customer. Returns the full module/lesson tree plus `completed_content_ids`, `progress_percent`, `is_assigned`, and `started`.

```javascript theme={null}
var course = getCourseForCustomer('javascript-basics');
if (course && course.is_assigned) {
  setVariable('progress', course.progress_percent);
}
```

### `getCoursesForCustomer()`

Fetch all courses the current customer is enrolled in. Same as `getCourses(true)`.

```javascript theme={null}
var myCourses = getCoursesForCustomer();
```

Each course includes `module_count`, `lesson_count`, `progress_percent`, `completed_content_ids`, and `is_assigned`.

### `getCoursesForCustomerByCategorySlug(categorySlug)`

Fetch the current customer's enrolled courses, filtered by category slug. Returns an empty array if the category has no enrolled courses.

```javascript theme={null}
var marketing = getCoursesForCustomerByCategorySlug('marketing');
setVariable('marketing_courses', marketing);
```

### `getCategoriesWithCoursesForCustomer()`

Get all course categories that contain at least one course the current customer is enrolled in, with the enrolled courses grouped per category. Useful for category-tab navigation on a member-facing courses page.

```javascript theme={null}
var categories = getCategoriesWithCoursesForCustomer();
// [
//   { category_slug: 'marketing', category_name: 'Marketing', courses: [...] },
//   { category_slug: 'design',    category_name: 'Design',    courses: [...] }
// ]
```

### `getCompletedLessonsCount(opts?)`

Count completed lessons across all enrolled courses, or for a specific course.

```javascript theme={null}
var totalDone = getCompletedLessonsCount();
var courseDone = getCompletedLessonsCount({ course: 'javascript-basics' });
```

### `getCourseProgress(opts?)`

Get a structured progress summary with per-course and overall completed/total lesson counts.

```javascript theme={null}
var progress = getCourseProgress();
// progress.completedLessons  → 12
// progress.totalLessons      → 48
// progress.courses → [{ slug, title, completedLessons, totalLessons, progressPercent }]
```

## Blog

### `getBlog(blogId?)`

Fetch a blog by ID. If no ID is provided, returns the default blog for the current domain.

```javascript theme={null}
var blog = getBlog();
if (blog) {
  setVariable('blog_name', blog.name);
}

// Or by specific ID
var specific = getBlog(5);
```

### `getBlogArticles(blogId, page?, perPage?)`

Fetch published blog articles with pagination.

| Parameter | Type   | Default | Description                 |
| --------- | ------ | ------- | --------------------------- |
| `blogId`  | number | —       | Blog ID (required)          |
| `page`    | number | `1`     | Page number                 |
| `perPage` | number | `12`    | Articles per page (max 100) |

```javascript theme={null}
var blog = getBlog();
if (blog) {
  var articles = getBlogArticles(blog.id, 1, 10);
  setVariable('article_count', articles.length);
  setVariable('articles', articles);
}
```

Each article includes a `body` field with the article content.

### `getBlogArticle(blogId, slug)`

Fetch a single published article by blog ID and slug.

```javascript theme={null}
var article = getBlogArticle(1, 'getting-started');
if (article) {
  setVariable('article_title', article.title);
  setVariable('article_body', article.body);
}
```

### `getBlogCategories(blogId)`

Get category summaries for a blog's published articles.

```javascript theme={null}
var categories = getBlogCategories(1);
setVariable('blog_categories', categories);
```

### `getRelatedArticles(blogId, articleId, limit?)`

Fetch related articles for a given article.

| Parameter   | Type   | Default | Description                         |
| ----------- | ------ | ------- | ----------------------------------- |
| `blogId`    | number | —       | Blog ID                             |
| `articleId` | number | —       | Article to find related content for |
| `limit`     | number | `4`     | Max related articles (1–24)         |

```javascript theme={null}
var related = getRelatedArticles(1, 42, 3);
setVariable('related_articles', related);
```

## Purchase Links

These functions generate URLs for the payment flow. They are synchronous.

### `buy(productCode)`

Generate a purchase link for a product.

```javascript theme={null}
var link = buy('premium-plan');
setVariable('buy_url', link);
```

### `upsell(productCode)`

Generate an upsell link (one-click purchase for existing customers).

```javascript theme={null}
var link = upsell('vip-addon');
setVariable('upsell_url', link);
```

### `downsell(productCode)`

Generate a downsell link (same mechanics as upsell).

```javascript theme={null}
var link = downsell('basic-addon');
setVariable('downsell_url', link);
```

### `decline(productCode)`

Generate a decline link (skip the upsell offer and proceed).

```javascript theme={null}
var link = decline('vip-addon');
setVariable('decline_url', link);
```

### `upsellUpgrade(oldSku, newSku)`

Generate a subscription upgrade link. Produces a JavaScript action URL that calls the server API to change the customer's subscription product. See [Subscription Upsells](/products/subscription-upsells).

```javascript theme={null}
var link = upsellUpgrade('monthly_basic', 'monthly_premium');
setVariable('upgrade_url', link);
```

### `upsellCancel()`

Generate a subscription cancellation link. Produces a JavaScript action URL that calls the server API to cancel the customer's subscription.

```javascript theme={null}
var link = upsellCancel();
setVariable('cancel_url', link);
```

## Assets

### `asset(path)`

Generate a full CDN URL for a brand asset.

```javascript theme={null}
var cssUrl = asset('/main.css');
var imgUrl = asset('/images/hero.jpg');

setVariable('css_url', cssUrl);
```

Automatically appends a cache-busting parameter when `?nc`, `?no_cache`, or `?preview_key` is present in the request.

## Visitor Control

### `whitelistVisitor()`

Whitelist the current visitor by setting an encrypted cookie. Whitelisted visitors bypass access restrictions.

```javascript theme={null}
var customer = getCustomer();
if (customer.email === 'admin@example.com') {
  whitelistVisitor();
}
```

### `blacklistVisitor()`

Blacklist the current visitor by setting an encrypted cookie.

```javascript theme={null}
if (request.query.block === 'true') {
  blacklistVisitor();
  redirect('/blocked');
}
```

## Session Items

These functions read and write custom session items that are **compatible with the template engine's `setSessionItem` / `getSessionItem`** functions. Values are stored with a `custom_` prefix and base64-encoded.

Use these when you need to share data between backend scripts and template-engine expressions on the same page or across pages within the same session.

### `setSessionItem(key, value)`

Store a custom value in the session. Keys are sanitized to `[a-zA-Z0-9_]`. Values over 64KB are silently dropped. Pass `null` to delete.

```javascript theme={null}
setSessionItem('preferred_plan', 'premium');
setSessionItem('quiz_score', '85');
```

### `getSessionItem(key)`

Retrieve a custom session item. Returns an empty string if not found.

```javascript theme={null}
var plan = getSessionItem('preferred_plan');
if (plan === 'premium') {
  setVariable('show_premium_banner', true);
}
```

### `clearSessionItem(key)`

Remove a custom session item.

```javascript theme={null}
clearSessionItem('preferred_plan');
```

<Note>
  For raw session access (without the `custom_` prefix and base64 encoding), use `session.get(key)` and `session.set(key, value)` from the [Actions](/backend-scripts/actions) page.
</Note>

## CRM Data

<Note>
  **Entries vs. Fields**: An **entry** is a single record or document stored in the CRM (like a row in a database, representing e.g. a specific check-in for a customer). A **field** (`fieldKey`) is a specific piece of data that lives inside an entry (like a column, e.g. "current\_weight").
</Note>

CRM entries are stored per brand. Every read/write is scoped by:

* **`brand_id`** — from the session (always applied server-side)
* **`slug`** — the CRM **entity** machine name from entity settings (e.g. `weight-tracker`), **required** on every call. This is **not** the same as `referenceType` (`customer`, `order`, …).
* **`reference_type` + `reference_id`** — which record the entry is attached to. Defaults: `referenceType` → `'customer'`, `referenceId` → current session **`customer_id`** when omitted or `null`.

Pass a **single options object** only (positional arguments are not supported). Missing `slug` or other required keys throws `TypeError`. Unknown `slug` for the brand also throws. Invalid field values still return `false` / `null` / `[]` after sanitization where applicable.

Slug aliases: `crmEntitySlug`, `crm_entity_slug`. Reference aliases: `reference_type`, `reference_id`. Field key: `fieldKey` or `field_key`.

Write functions (`setCrmField`, `setCrmFields`, `addCrmFieldValue`, `clearCrmEntries`) accept an optional **`async`** parameter (default `false`). When `true`, the write runs in the background — faster but with eventual consistency. Read functions are always synchronous.

```javascript theme={null}
getCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight' });

getCrmFields({ slug: 'weight-tracker', fieldKeys: ['current_weight', 'goal_weight'] });

setCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight', value: 182, force: true });

setCrmFields({
  slug: 'weight-tracker',
  fields: [
    { fieldKey: 'current_weight', value: 182 },
    { fieldKey: 'goal_weight', value: 165 }
  ]
});

// Another record type / id
setCrmField({ slug: 'weight-tracker', referenceType: 'order', referenceId: '12345', fieldKey: 'status', value: 'shipped' });
```

### `ensureCrmEntity({ slug, name?, fields?, ... })`

Idempotently provisions a CRM entity (and optionally seeds its field schema). Safe to call on every page render — existing entities and fields are never overwritten, only missing keys are inserted.

Use this at the top of a backend script in templates that ship to many brands so the CRM entity is auto-created on first use, without requiring an admin to pre-configure it.

| Property       | Type   | Default      | Description                                                                                                    |
| -------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------------------- |
| `slug`         | string | **required** | Machine name for the entity (e.g. `member-settings`). Must match `^[a-z0-9][a-z0-9_-]*$`.                      |
| `name`         | string | slug         | Human-readable entity name shown in the admin UI.                                                              |
| `singularName` | string | `null`       | Singular label (e.g. "Member Setting"). Alias: `singular_name`.                                                |
| `pluralName`   | string | `null`       | Plural label (e.g. "Member Settings"). Alias: `plural_name`.                                                   |
| `icon`         | string | `null`       | Icon name (Lucide icon, admin UI).                                                                             |
| `color`        | string | `null`       | Accent color (CSS color string).                                                                               |
| `fields`       | array  | `[]`         | Optional field schema. Each item: `{ key, label?, type?, options?, validation_rules?, visible_to_team_ids? }`. |

Field `type` values: `text`, `textarea`, `email`, `tel`, `url`, `number`, `integer`, `decimal`, `currency`, `date`, `datetime`, `time`, `select`, `multiselect`, `checkbox`, `boolean`, `radio`, `json`, `rich_text`, `wysiwyg` (rich text fields: values are HTML strings). For `select`/`multiselect`/`radio` pass `options: ['a','b']` or `[{ value: 'a', label: 'A' }]`.

Returns:

```javascript theme={null}
{
  id: 42,
  slug: 'member-settings',
  created: true,        // false if entity already existed
  fields_added: 3,      // newly inserted fields
  fields_skipped: 2     // already existed (left untouched)
}
```

Example:

```javascript theme={null}
ensureCrmEntity({
  slug: 'member-settings',
  name: 'Member Settings',
  icon: 'settings',
  fields: [
    { key: 'preferred_name', label: 'Preferred name', type: 'text' },
    { key: 'time_zone',      label: 'Time zone',      type: 'select',
      options: ['UTC', 'Europe/London', 'America/New_York'] },
    { key: 'marketing_opt_in', label: 'Marketing opt-in', type: 'checkbox' },
  ],
});

// Now safe to read/write — the entity definitely exists.
var name = getCrmField({ slug: 'member-settings', fieldKey: 'preferred_name' });
```

**Limits**: max 5 `ensureCrmEntity` calls per page execution. Exceeding the limit returns `{ id: null, created: false, error: 'rate_limited' }`.

### `getCrmEntries({ slug, referenceType?, referenceId?, limit? })`

Fetch CRM entries for one entity slug and one reference.

| Property        | Type   | Default               | Description                          |
| --------------- | ------ | --------------------- | ------------------------------------ |
| `slug`          | string | **required**          | CRM entity slug from entity settings |
| `referenceType` | string | `'customer'`          | Linked record type                   |
| `referenceId`   | string | session `customer_id` | Linked record id                     |
| `limit`         | number | `50`                  | Max entries (max 200)                |

```javascript theme={null}
var entries = getCrmEntries({ slug: 'weight-tracker', limit: 50 });

for (var i = 0; i < entries.length; i++) {
  console.log(entries[i].title, entries[i].values_flat);
}
```

Each entry object contains:

```javascript theme={null}
{
  id: 'abc123',
  reference_type: 'customer',
  reference_id: 'xyz789',
  values_flat: {
    current_weight: 182,
    goal_weight: 165,
    last_checkin: '2026-03-15'
  },
  created_at: '2026-03-15T10:00:00Z',
  updated_at: '2026-03-20T14:30:00Z'
}
```

### `getCrmField({ slug, fieldKey, referenceType?, referenceId? })`

Reads one field from the first matching entry for that **slug** + reference.

| Property        | Type   | Default               | Description                                      |
| --------------- | ------ | --------------------- | ------------------------------------------------ |
| `slug`          | string | **required**          | CRM entity slug                                  |
| `fieldKey`      | string | **required**          | Field key (e.g. `current_weight`, `goal_weight`) |
| `referenceType` | string | `'customer'`          | Linked record type                               |
| `referenceId`   | string | session `customer_id` | Linked record id                                 |

```javascript theme={null}
var weight = getCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight' });

if (weight && Number(weight) <= 165) {
  setVariable('show_goal_reached', true);
}
```

### `getCrmFields({ slug, fieldKeys, referenceType?, referenceId? })`

Reads multiple fields from the first matching entries for that **slug** + reference. Returns an object mapping `fieldKey` -> `value`.

| Property        | Type   | Default               | Description                                                    |
| --------------- | ------ | --------------------- | -------------------------------------------------------------- |
| `slug`          | string | **required**          | CRM entity slug                                                |
| `fieldKeys`     | array  | **required**          | Array of field keys (e.g. `['current_weight', 'goal_weight']`) |
| `referenceType` | string | `'customer'`          | Linked record type                                             |
| `referenceId`   | string | session `customer_id` | Linked record id                                               |

```javascript theme={null}
var fields = getCrmFields({ slug: 'weight-tracker', fieldKeys: ['current_weight', 'goal_weight'] });

if (fields.current_weight && Number(fields.current_weight) <= 165) {
  setVariable('show_goal_reached', true);
}
```

### `setCrmField({ slug, fieldKey, value, force?, referenceType?, referenceId?, async? })`

Updates an existing entry's field or creates a new entry under that entity slug and reference.

| Property        | Type    | Default               | Description                                                          |
| --------------- | ------- | --------------------- | -------------------------------------------------------------------- |
| `slug`          | string  | **required**          | CRM entity slug                                                      |
| `fieldKey`      | string  | **required**          | Field key to set                                                     |
| `value`         | any     | **required**          | Must be present (`value: 0` / `false` allowed)                       |
| `force`         | boolean | `false`               | Skip per-key entry scan — merge into the newest entry directly       |
| `referenceType` | string  | `'customer'`          | Linked record type                                                   |
| `referenceId`   | string  | session `customer_id` | Linked record id                                                     |
| `async`         | boolean | `false`               | When `true`, write runs in background (faster, eventual consistency) |

```javascript theme={null}
setCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight', value: 182 });
setCrmField({ slug: 'weight-tracker', fieldKey: 'last_checkin', value: new Date().toISOString() });
```

**Performance tip:** When setting multiple fields in one script, pass `force: true`. Without it, each call searches all entries (up to 100) to find the document containing that key. With `force`, it fetches only the newest entry (limit 1) and merges the field into it — much faster for sequential writes:

```javascript theme={null}
setCrmField({ slug: 'weight-tracker', fieldKey: 'current_weight', value: 182, force: true });
setCrmField({ slug: 'weight-tracker', fieldKey: 'goal_weight', value: 165, force: true });
setCrmField({ slug: 'weight-tracker', fieldKey: 'last_checkin', value: today, force: true });
```

### `setCrmFields({ slug, fields, force?, referenceType?, referenceId?, async? })`

Sets multiple field values for the same CRM entity + reference in one read/write cycle. This is the preferred API when one script needs to write several fields at once.

| Property        | Type    | Default               | Description                                                |
| --------------- | ------- | --------------------- | ---------------------------------------------------------- |
| `slug`          | string  | **required**          | CRM entity slug                                            |
| `fields`        | array   | **required**          | Non-empty array of `{ fieldKey, value }` objects           |
| `force`         | boolean | `false`               | Skip per-key scan and merge into the newest entry directly |
| `referenceType` | string  | `'customer'`          | Linked record type                                         |
| `referenceId`   | string  | session `customer_id` | Linked record id                                           |
| `async`         | boolean | `false`               | When `true`, write runs in background                      |

```javascript theme={null}
setCrmFields({
  slug: 'weight-tracker',
  fields: [
    { fieldKey: 'current_weight', value: 182 },
    { fieldKey: 'goal_weight', value: 165 },
    { fieldKey: 'last_checkin', value: today }
  ]
});
```

Snake\_case keys are also accepted inside `fields`:

```javascript theme={null}
setCrmFields({
  slug: 'affiliate-requests',
  referenceType: 'affiliate_request',
  referenceId: 'leon@elasticfunnels.io',
  fields: [
    { field_key: 'affiliate_experience', value: 'Wellness' },
    { field_key: 'niche', value: 'Health' }
  ]
});
```

Behavior matches `setCrmField`:

* If an existing entry for the same reference already contains one of the requested keys, that entry is updated.
* If the keys are new but the reference already has entries, the newest entry is reused.
* A new CRM entry is created only when that reference has no entry yet.

### `addCrmFieldValue({ slug, fieldKey, value, referenceType?, referenceId?, async? })`

Creates a **new** CRM entry with the given field value. Unlike `setCrmField` which upserts a single value per key, this always creates a new entry — enabling multiple values for the same key over time.

| Property        | Type    | Default               | Description                                             |
| --------------- | ------- | --------------------- | ------------------------------------------------------- |
| `slug`          | string  | **required**          | CRM entity slug                                         |
| `fieldKey`      | string  | **required**          | Field key to store the value under                      |
| `value`         | any     | **required**          | The value to store (string, number, boolean, or object) |
| `referenceType` | string  | `'customer'`          | Linked record type                                      |
| `referenceId`   | string  | session `customer_id` | Linked record id                                        |
| `async`         | boolean | `false`               | When `true`, write runs in background                   |

```javascript theme={null}
addCrmFieldValue({
  slug: 'weight-tracker',
  fieldKey: 'weighin_history',
  value: { date: today, weight: 182, notes: 'post-workout' }
});
```

### `getCrmFieldValues({ slug, fieldKey, limit?, referenceType?, referenceId? })`

Collects all values for a given field key across CRM entries. Returns an array of the raw values (string, number, object, etc.), newest first.

| Property        | Type   | Default               | Description                     |
| --------------- | ------ | --------------------- | ------------------------------- |
| `slug`          | string | **required**          | CRM entity slug                 |
| `fieldKey`      | string | **required**          | Field key to collect values for |
| `limit`         | number | `50`                  | Max values to return (max 200)  |
| `referenceType` | string | `'customer'`          | Linked record type              |
| `referenceId`   | string | session `customer_id` | Linked record id                |

```javascript theme={null}
var history = getCrmFieldValues({ slug: 'weight-tracker', fieldKey: 'weighin_history', limit: 10 });

for (var i = 0; i < history.length; i++) {
  console.log(history[i].date, history[i].weight);
}
```

### `clearCrmEntries({ slug, referenceType?, referenceId?, async? })`

Deletes **all** CRM entries for the given entity slug and reference. Returns `{ ok: boolean, deleted: number }`.

| Property        | Type    | Default               | Description                            |
| --------------- | ------- | --------------------- | -------------------------------------- |
| `slug`          | string  | **required**          | CRM entity slug                        |
| `referenceType` | string  | `'customer'`          | Linked record type                     |
| `referenceId`   | string  | session `customer_id` | Linked record id                       |
| `async`         | boolean | `false`               | When `true`, deletes run in background |

```javascript theme={null}
var result = clearCrmEntries({ slug: 'weight-tracker' });
console.log(result.deleted, 'entries removed');
```

### `moveCrmLead({ slug, pipelineId?, pipelineSlug?, stageId?, stageSlug?, referenceType?, referenceId?, allEntries?, async? })`

Moves a CRM entry to a specific pipeline and stage. Either `pipelineId` or `pipelineSlug` is required; either `stageId` or `stageSlug` is required.

| Property        | Type    | Default               | Description                                                     |
| --------------- | ------- | --------------------- | --------------------------------------------------------------- |
| `slug`          | string  | **required**          | CRM entity slug                                                 |
| `pipelineId`    | number  | —                     | Pipeline id (or use `pipelineSlug`)                             |
| `pipelineSlug`  | string  | —                     | Pipeline slug (or use `pipelineId`)                             |
| `stageId`       | number  | —                     | Stage id (or use `stageSlug`)                                   |
| `stageSlug`     | string  | —                     | Stage slug (or use `stageId`)                                   |
| `referenceType` | string  | `'customer'`          | Linked record type                                              |
| `referenceId`   | string  | session `customer_id` | Linked record id                                                |
| `allEntries`    | boolean | `false`               | When `true`, updates all matching entries (not just the newest) |
| `async`         | boolean | `false`               | When `true`, update runs in background                          |

Returns `{ ok, moved, totalMatched, pipeline_id, stage_id }` on success, or `{ ok: false, moved: 0, error }`.

```javascript theme={null}
var result = moveCrmLead({ slug: 'deals', pipelineSlug: 'sales', stageSlug: 'qualified' });

if (!result.ok) {
  console.log('Move failed:', result.error);
}
```

### `moveCrmToStage({ slug, stageId?, stageSlug?, pipelineId?, pipelineSlug?, referenceType?, referenceId?, allEntries?, async? })`

Moves a CRM entry to a specific stage. The pipeline is resolved from the stage automatically — only `stageId` or `stageSlug` is strictly required (pipeline id/slug can narrow the lookup if stage slugs are not unique across pipelines).

```javascript theme={null}
moveCrmToStage({ slug: 'deals', stageSlug: 'won' });
```

### `moveCrmToPipeline({ slug, pipelineId?, pipelineSlug?, stageId?, stageSlug?, referenceType?, referenceId?, allEntries?, async? })`

Moves a CRM entry into a pipeline. The entry lands on the pipeline's first stage unless a specific stage is provided.

```javascript theme={null}
moveCrmToPipeline({ slug: 'deals', pipelineSlug: 'onboarding' });
```

### `moveCrmToEntity({ fromSlug, toSlug, fieldMap, referenceType?, referenceId?, pipelineId?, pipelineSlug?, stageId?, stageSlug?, removeFromOld?, async? })`

Copies field values from one CRM entity to another for the same reference. Use this to promote a lead to a deal, or migrate data between entity types.

| Property                      | Type            | Default               | Description                                                               |
| ----------------------------- | --------------- | --------------------- | ------------------------------------------------------------------------- |
| `fromSlug`                    | string          | **required**          | Source entity slug                                                        |
| `toSlug`                      | string          | **required**          | Target entity slug (must differ from `fromSlug`)                          |
| `fieldMap`                    | object          | **required**          | `{ sourceFieldKey: targetFieldKey }` mapping — at least one pair required |
| `referenceType`               | string          | `'customer'`          | Linked record type                                                        |
| `referenceId`                 | string          | session `customer_id` | Linked record id                                                          |
| `pipelineId` / `pipelineSlug` | number / string | —                     | Optional pipeline to place the new entry in                               |
| `stageId` / `stageSlug`       | number / string | —                     | Optional stage within that pipeline                                       |
| `removeFromOld`               | boolean         | `false`               | When `true`, deletes all source entries after copying                     |
| `async`                       | boolean         | `false`               | When `true`, write runs in background                                     |

Returns `{ ok, moved, removed_from_old }` where `moved` is the number of fields copied.

```javascript theme={null}
var result = moveCrmToEntity({
  fromSlug: 'leads',
  toSlug: 'deals',
  fieldMap: {
    lead_score: 'initial_score',
    source: 'lead_source'
  },
  pipelineSlug: 'sales',
  stageSlug: 'new',
  removeFromOld: false
});

if (result.ok) {
  console.log(result.moved, 'fields copied');
}
```
