# Aether API V3 Frontend Integration Guide (Svelte/TypeScript)

This guide defines the standards for interacting with the **Aether API V3 CRUD** and **Action** endpoints.

---

## 1. Authentication and Security (Mandatory)

V3 architecture enforces strict **Multi-Tenant Isolation** and **Machine Authorization**. Requests require two levels of validation.

### A. The "Entry Ticket" (API Key)
**Mandatory for all requests.** identifies the application or client.
*   **Header:** `x-aether-api-key: <your_app_key>`
*   **Status Code:** `403 Forbidden` if missing or invalid.

### B. The "Visa" (Account Context)
Required for any non-public data (Journals, Badges, Users, etc.).
1.  **Standard Access**: Provide the `x-account-id` (the random string ID).
    *   **Header:** `x-account-id: <account_id>`
2.  **Administrative Bypass**: For authorized scripts needing global access.
    *   **Header:** `x-no-account-id: bypass`
  *   **Scope:** Narrow escape hatch only. Keep it limited to allowlisted bootstrap/public/global-default paths and prefer `x-account-id` or JWT-backed requests everywhere else.
3.  **Token Access**: Provide a **JWT** in the query string.
    *   **Query Param:** `?jwt=<token>`
4.  **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
  *   `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
  *   Only explicit `x-no-account-id: bypass` should strip account context.

> [!NOTE]
> The `x-no-account-id` path should continue to shrink over time. If you need a new use, document why `x-account-id` or JWT cannot cover it and mark the use as temporary unless it is a hard bootstrap/global-default requirement.

> [!CAUTION]
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.

---

## 2. Bootstrapping (The FQDN Handshake)

When the frontend first loads and doesn't know the `account_id`, it performs a "handshake" using its domain name.

**Endpoint:** `POST /v3/crud/site_domain/search`
**Body:**
```json
{
  "and": [
    { "field": "fqdn", "op": "eq", "value": "demo.oneskyit.com" }
  ]
}
```
**Results:**
*   Returns 200 + a list containing the `account_id` (random string ID) and `site_id` (random string ID).
*   ** デザイン Choice:** If the domain is not found, it returns **200 OK with an empty list `[]`**. It is NOT a 404.

> **Access Key Support**
>
> Some client deployments restrict their domain via an access key passed in the browser URL (e.g. `?key=abc123`). The frontend reads this param and forwards it as `access_key` in the POST body.
>
> **How to pass the key:**
> ```json
> {
>   "and": [
>     { "field": "fqdn", "op": "eq", "value": "client.example.com" },
>     { "field": "access_key", "op": "eq", "value": "abc123" }
>   ]
> }
> ```
> If `key` is absent, empty, or falsy — **omit `access_key` from the payload entirely**. Do not send `"access_key": ""`.
>
> **Server behavior:**
> - `site_access_key` (site-level key) takes priority. If set, all domains under that site require it.
> - `site_domain_access_key` (domain-level key) is used as fallback when `site_access_key` is not set.
> - A domain is **public** only when **both** key columns are NULL/empty.
> - Falsy `access_key` values are ignored server-side as a safety net.
> - Match → `200` with the record. No match → `200` with empty list `[]`.
> - Do **not** use `access_code_kv_json` for this — that field is for UI features only.
>
> | Browser URL | `access_key` in payload | Result |
> |---|---|---|
> | `https://dev-demo.oneskyit.com` | *(omit)* | ✅ Returns record (public) |
> | `https://client.example.com/?key=correct` | `"correct"` | ✅ Returns record |
> | `https://client.example.com/` | *(omit)* | ❌ Empty (key required) |
> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty (wrong key) |
> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty (key required) |
>
---

## 3. Standard CRUD Patterns

### A. GET by ID
Used when the ID is known.
*   **Endpoint:** `GET /v3/crud/{obj_type}/{id}`
*   **Security:** Returns 403 if the record doesn't belong to your `x-account-id`.

### B. POST Search
The primary way to retrieve data.
*   **Endpoint:** `POST /v3/crud/{obj_type}/search`
*   **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects.

### C. POST Create / PATCH Update
Modify data in the system.
*   **Endpoints:**
    *   `POST /v3/crud/{obj_type}/`
    *   `PATCH /v3/crud/{obj_type}/{id}`
*   **Strict Mode (Default):** The API validates your payload against the Pydantic model. If you send fields that do not exist in the model, the database might return a 400 "Unknown column" error.
*   **Permissive Mode (Header):** To allow the frontend to send "extra" fields (like local UI state) without causing errors, use the following header:
    *   **Header:** `x-ae-ignore-extra-fields: true`
    *   **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database.

#### `*_json` field serialization — do NOT pre-stringify in route/component code

The frontend API wrappers (`src/lib/ae_api/api_post__crud_obj.ts` for V3, `src/lib/api/api.ts` for legacy CRUD) automatically serialize any field whose name ends in `_json` (e.g. `cfg_json`, `data_json`) before sending. They pretty-print with 2-space indent via an internal `serialize_json_field_pretty()` helper.

**Pass `*_json` fields as plain JS objects from routes and components.** The serialization layer handles the rest.

```ts
// ✅ Correct — pass as plain object; V3 wrapper serializes it
await update_ae_obj__site({ site_id, data_kv: { cfg_json: { jitsi_token_endpoint: url } } });

// ❌ Wrong — double-encodes the JSON string (the wrapper would stringify an already-stringified value)
await update_ae_obj__site({ site_id, data_kv: { cfg_json: JSON.stringify({ jitsi_token_endpoint: url }) } });
```

The V3 wrapper (`api_post__crud_obj.ts`) only serializes when `typeof value === 'object'`, so it will not double-encode a plain string. The legacy wrapper (`api.ts`) stringifies unconditionally, so pre-stringifying there **will** produce double-encoded JSON. In both cases, the right answer is to pass the raw object and let the layer handle it.

### D. ID Fields in Responses (Vision ID Convention)

> [!IMPORTANT]
> **V3 responses always use random string IDs — never database integers.**

All V3 responses — `POST` create, `GET` single, `GET` list, search, and `PATCH` update — contain:

| Field | Type | Use |
| :--- | :--- | :--- |
| `{obj_type}_id` | `string` | **Primary public ID.** Use this for subsequent `PATCH` calls and UI routing. |
| `{obj_type}_id_random` | `string` | Legacy alias. Same value as `{obj_type}_id`. Present for backward compat only. |

**Example — create then immediately PATCH:**
```ts
const created = await postArchiveContent(archiveId, payload);
const newId = created.data.archive_content_id; // random string e.g. "xK9mP3qRtL2"

// Use it directly in the PATCH URL — no lookup needed
await patchArchiveContent(newId, { name: 'Updated Name' });
// PATCH /v3/crud/archive/{archive_id}/archive_content/{newId}
```

> **Note on `_id_random` suffix:** The `{obj_type}_id_random` field is a legacy artifact from the pre-Vision model. Once you confirm `{obj_type}_id` is a random string (length 11–22), you do not need `_id_random` as a fallback. New code should only read `{obj_type}_id`.

---

## 4. V3 Uniform Lookup System

The V3 Lookup system provides a hierarchical, deduplicated interface for standardized reference tables (Countries, Timezones, etc.). It supports global defaults, account-level overrides, and object-level overrides, with optional site-specific whitelisting.

### How the hierarchy works

Each lookup table (`lu_v3_country`, `lu_v3_time_zone`, etc.) can hold multiple rows for the same logical item at different scopes:

| Scope | `account_id` | `for_type` / `for_id` | Wins over |
|---|---|---|---|
| Global default | `NULL` | `NULL` / `NULL` | nothing |
| Account override | set | `NULL` / `NULL` | Global default |
| Object override | set | set | Account override + Global default |

The API uses `ROW_NUMBER() PARTITION BY group` to collapse all rows for the same item down to the single highest-priority winner before returning results. **`group` is the identity key** — it is what makes two rows "the same item competing for priority."

> [!IMPORTANT]
> **The `group` field is not a display label.** It is the deduplication key. Each lookup type uses a different natural key for `group`:
>
> | Lookup type | `group` value | Example |
> |---|---|---|
> | `country` | ISO alpha-2 code | `"US"`, `"CA"`, `"GB"` |
> | `country_subdivision` | subdivision code | `"US-NY"`, `"CA-ON"` |
> | `time_zone` | IANA timezone name | `"America/New_York"`, `"US/Eastern"` |
>
> For `time_zone`, `group` and `name` must always be identical — there is no concept of "override all US timezones as a group." Each timezone is its own identity.

### A. List Lookups

Retrieve the deduplicated, ranked list for a lookup type.

*   **Endpoint:** `GET /v3/lookup/{lu_type}/list`
*   **Available Types:** `country`, `country_subdivision`, `time_zone`
*   **Parameters:**
    *   `site_id` (Optional): Random ID of the site — applies a **Whitelist Policy** (see §C).
    *   `only_priority` (Optional): `true` returns only `priority=1` items (e.g., common time zones).
    *   `for_type` / `for_id` (Optional): Object context — activates object-level override matching.
    *   `include_disabled` (Optional): `true` includes shadowed/disabled records (useful for admin views).

**Frontend keying:** Always key Svelte `{#each}` blocks on `group`, not `id` or `name`. `group` is guaranteed unique in the response. Keying on `id` will break if an account override wins (different `id`, same logical item).

### B. Resolve Identity

Resolves a string to a single lookup record.

*   **Endpoint:** `GET /v3/lookup/{lu_type}/resolve?q=VALUE`
*   **Usage:** Use when you have an external code (e.g., ISO `"US"`) and need the full Aether record. Scans `name`, `group`, and other identity fields.

### C. Site Whitelist Policy

To restrict which lookup items appear for a specific site, add a `lookup_policy` to `site.cfg_json`:

```json
{
  "lookup_policy": {
    "country": ["US", "CA", "GB"],
    "time_zone": ["America/New_York", "US/Eastern"]
  }
}
```

> **Whitelist values must match the `group` field** — i.e., the natural key for that type (ISO code for country, IANA name for time zone). Using a display name will silently return no results for that item.

### D. Adding and managing client overrides

When a client needs a customized label or wants to hide/reorder lookup items, create override records rather than modifying global defaults.

**Rules:**
1. **Never modify global default rows** (`account_id = NULL`). Those are shared across all accounts. Any change there affects every client.
2. **Set `group` to the exact same value as the global default row** for the item you are overriding. If `group` doesn't match, the override creates a new item instead of replacing the existing one.
3. **Set `account_id`** to the client's account ID. Leave `for_type` / `for_id` null unless the override is specific to a single object (e.g., one site).

**Example — rename "US/Eastern" for one account:**

```sql
INSERT INTO lu_v3_time_zone
  (account_id, name, name_override, `group`, enable, priority, sort)
VALUES
  (42, 'US/Eastern', 'Eastern Time (Client Label)', 'US/Eastern', 1, 1, 50);
```

The `name_override` field is the display label the frontend should prefer when set. `group = 'US/Eastern'` ensures this row competes with — and wins over — the global default in the `PARTITION BY group` deduplication.

**To disable an item for one account** (hide it from their dropdowns):

```sql
INSERT INTO lu_v3_time_zone
  (account_id, name, `group`, enable)
VALUES
  (42, 'US/Samoa', 'US/Samoa', 0);
```

Setting `enable = 0` on an account-scoped row shadows the global default for that account only.

**To remove a client override** (revert to global default):

Simply delete the row where `account_id = <client>` and `group = '<item>'`. The global default row is unaffected and immediately resumes winning.

### E. Adding new global lookup items

When seeding new lookup data (e.g., adding timezones in bulk):

1. Set `group = name` for every row (for `time_zone`). This is a hard invariant — if `group` is set to a regional label like `"United States"` instead of the timezone name, the entire group collapses to a single winner and all but one entry disappear from the API response.
2. Set `account_id = NULL` and `for_type = NULL` / `for_id = NULL` for global defaults.
3. After seeding, verify with:
   ```sql
   -- Should return 0 rows; any result means multiple items will collapse into one
   SELECT `group`, COUNT(*) AS cnt
   FROM lu_v3_time_zone
   WHERE account_id IS NULL
   GROUP BY `group`
   HAVING cnt > 1;
   ```

---

## 5. Event File Data Retrieval (Hosted Files)

Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file`). The Hosted File itself is a metadata record for binary content (files), which is accessed via separate Action endpoints (e.g., `/v3/action/hosted_file/download`). This API endpoint provides metadata about the associated hosted file. To retrieve this additional metadata:

*   **Endpoint:** `GET /v3/crud/event_file/{event_file_id}`
*   **Query Parameter:** Add `inc_hosted_file=true`
    *   Example: `/v3/crud/event_file/<event_file_id>?inc_hosted_file=true`

**Response Impact:**

1.  **Top-Level Convenience Fields:** The response will include top-level fields for commonly needed hosted file data. These are populated directly from the SQL view via JOINs.
    *   `hosted_file_hash_sha256` (string)
    *   `hosted_file_subdirectory_path` (string)
    *   `hosted_file_content_type` (string)
    *   `hosted_file_size` (string - in bytes)
2.  **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc.

---

## 6. Hosted File Actions: Convert & Clip (Frontend Notes)

These helper endpoints let the frontend request small server-side transformations without uploading new blobs. They return a newly-created `hosted_file` metadata object on success.

- **Convert (PDF → Image)**
  - Method: `GET`
  - Path: `/v3/action/hosted_file/{hosted_file_id}/convert_file`
  - Required query params: `link_to_type`, `link_to_id`
  - Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_conversion`), `to_type` (defaults to `webp`)
  - Auth: standard V3 headers (`x-aether-api-key`, `x-account-id` / `x-no-account-id` / `?jwt=`)
  - Behavior: converts the first page of a PDF to `webp` or `png`, saves a new `hosted_file`, and returns its metadata. Returns 400 on failure.

- **Clip Video**
  - Method: `GET`
  - Path: `/v3/action/hosted_file/{hosted_file_id}/clip_video`
  - Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`)
  - Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool)
  - Auth: standard V3 headers
  - Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure.
   - Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
     - Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize.
     - For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints.
     - Returns 400 on synchronous failure; returns 202 when scheduled successfully.

Frontend guidance:

- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
- After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file.
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead.
 - These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.

---

## 8. Email Send Action

Send a transactional email via the Aether API.

- **Method:** `POST`
- **Path:** `/v3/action/email/send`
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)

**Request body:**
```json
{
  "from_email": "noreply@example.com",
  "from_name": "Example App",
  "to_email": "user@example.com",
  "to_name": "Alice Smith",
  "subject": "Your login link",
  "body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
  "body_text": "Visit ... to log in.",
  "cc_email": null,
  "bcc_email": null
}
```

**Query params:**

| Parameter | Type | Default | Description |
|---|---|---|---|
| `test` | bool | `false` | Simulate send without delivering |

**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.

> **Replaces:** `POST /util/email/send` (disabled as of May 2026).

---

## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)

Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.

- **Endpoint:** `POST /event/{event_id}/badge/import/zoom_csv`
- **Auth:** include `x-aether-api-key` (if required) and account context via `x-account-id: <ACCOUNT_ID>`. Admin bypass (`x-no-account-id: bypass`) or `?jwt=<token>` are accepted per site policy.
- **Request:** `multipart/form-data` with single file field `file` (Zoom CSV). Query params:
  - `begin_at` (int, default `0`)
  - `end_at` (int, default `20000`)
  - `return_detail` (bool, default `false`)
  - Delimiter is auto-detected; Zoom CSV layout: row 1 = metadata, row 2 = blank, row 3 = headers (the backend skips the first two rows).

Behavior / notes:
- The handler forces `Registrant email` to be used as the `external_id`. `Unique identifier` is used as `external_registration_id` only when it is meaningful (placeholders like `N/A`, `NA`, `UNKNOWN` are ignored).
- Per-ticket custom fields are parsed (Organization, Job title, Phone, Address lines, City, State/Province, Postal/Zip, Country, etc.).
- Marketing-consent values are mapped to `agree_to_tc` and `allow_tracking`.
- TEMP AXONIUS MAPPING: the import temporarily defaults `event_badge_template_id` to `21` and `event_badge_template_id_random` to `RKYp2HcQm9o`. Ticket-name → `badge_type_code` mapping is applied for some labels (e.g., contains "sponsor" → `sponsor`; contains "attend"/"attendee" → `attendee`). This mapping is temporary (April 2026) — surface this to staff.
- Rows missing `Registrant email` are skipped.
- The server upserts via existing backend methods and creates/updates `event_person`, `event_person_profile`, and `event_badge` records as needed.

Frontend guidance:
- UI must be staff-only and should validate an `event_id` is selected.
- For large files, use `begin_at`/`end_at` to process in chunks.
- Prefer `return_detail=false` for large imports to reduce payload size.

Common errors:
- `403` — missing/invalid account context or API key.
- `404` — event not found.
- `500` — file save or processing error.

Example curl (replace placeholders):
```bash
curl -v -X POST "https://api.example.com/event/<EVENT_ID>/badge/import/zoom_csv?begin_at=0&end_at=20000&return_detail=false" \
  -H "x-aether-api-key: <API_KEY>" \
  -H "x-account-id: <ACCOUNT_ID>" \
  -F "file=@/path/to/zoom_export.csv"
```

Sample success (summary mode, `return_detail=false`):
```json
{
  "data": [
    {
      "event_id": "xK9mP3qRtL2",
      "event_id_random": "xK9mP3qRtL2",
      "external_id": "alice@example.com",
      "given_name": "Alice",
      "family_name": "Smith",
      "email": "alice@example.com"
    }
  ],
  "meta": {
    "status_code": 200,
    "status_name": "OK",
    "success": true,
    "data_type": "list",
    "data_list_count": 1
  }
}
```

Sample success (detailed, `return_detail=true`) — `data` contains full `event_person` objects with nested `event_badge` (may include temporary `event_badge_template_id`: `21` and `event_badge_template_id_random`: `RKYp2HcQm9o`).

Paste this section into the guide as a temporary Axonius-specific note (April 2026). Consider linking staff to a sample Zoom CSV for QA.

---

## 7. User Actions (`/v3/action/user/`)

Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.

> [!IMPORTANT]
> **Migration from legacy `/user/*` routes:** The table below maps each legacy endpoint to its V3 replacement. Run both in parallel during transition; remove legacy routes once traffic logs confirm they are quiet.
>
> | Legacy | V3 Replacement |
> |---|---|
> | `GET /user/authenticate` | `POST /v3/action/user/authenticate` |
> | `POST /user/verify_password` | `POST /v3/action/user/verify_password` |
> | `PATCH /user/{id}/change_password` | `POST /v3/action/user/{id}/change_password` |
> | `GET /user/{id}/new_auth_key` | `GET /v3/action/user/{id}/new_auth_key` |
> | `GET /user/{id}/email_auth_key_url` | `GET /v3/action/user/{id}/email_auth_key_url` |
> | `GET /user/lookup` | `POST /v3/crud/user/search` |
> | `GET /user/lookup_email` | `POST /v3/crud/user/search` |
> | `GET /user/lookup_username` | `POST /v3/crud/user/search` |

### A. Authenticate

Authenticate a user by **username + password** or **user_id + auth_key**.

- **Method:** `POST`
- **Path:** `/v3/action/user/authenticate`
- **Auth:** `x-aether-api-key` + `x-account-id` (scopes username lookups to the correct account)
- **Security improvement:** Credentials are in the **POST body**, not query params — safe from URL logging.

**Request body:**
```json
{ "username": "scott", "password": "MyPassword123!" }
```
or:
```json
{ "user_id": "<user_id_random>", "auth_key": "<one_time_key>", "valid_email": true }
```

- `valid_email` (optional `bool`): if `true`, marks `email_verified = true` on success.
- `inc_user_role_list` (optional query param, default `false`): include role list in the returned user object.

**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).

**Errors:** `400` missing credentials, `403` wrong password / account disabled / account not yet enabled / account expired, `404` user not found.

> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.

---

### B. Verify Password

Check a user's current password without changing it.

- **Method:** `POST`
- **Path:** `/v3/action/user/verify_password`
- **Auth:** `x-aether-api-key` + `x-account-id`

**Request body:**
```json
{ "user_id": "<user_id_random>", "current_password": "MyPassword123!" }
```
or use `"username"` instead of `"user_id"` to look up by username within the account.

**Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.

---

### C. Change Password

Change a user's password. Optionally verify the current password first.

- **Method:** `POST`
- **Path:** `/v3/action/user/{user_id}/change_password`
- **Auth:** `x-aether-api-key` + `x-account-id`

**Request body:**
```json
{ "new_password": "NewPassword456!", "current_password": "MyPassword123!" }
```

- `new_password` is required (minimum 10 characters).
- `current_password` is optional. If provided, it is verified before the change is applied. Omit it for admin-driven resets.

**Response:** `data: true` on success. `403` if `current_password` provided but wrong.

---

### D. Generate New Auth Key

Generate a fresh one-time-use auth key for the user and write it to the DB.

- **Method:** `GET`
- **Path:** `/v3/action/user/{user_id}/new_auth_key`
- **Auth:** `x-aether-api-key` + `x-account-id`

**Response:**
```json
{ "data": { "auth_key": "<new_key>" } }
```

The returned key can then be passed to `/authenticate` (as `auth_key`) or embedded in a login URL. The user record must have `allow_auth_key = true` for key-based authentication to work.

---

### E. Email Auth Key URL

Generate a new auth key and email a one-time login link to the user's email address.

- **Method:** `GET`
- **Path:** `/v3/action/user/{user_id}/email_auth_key_url`
- **Auth:** `x-aether-api-key` + `x-account-id`

**Query Parameters:**

| Parameter | Type | Default | Description |
|---|---|---|---|
| `root_url` | `string` | *(required)* | Base URL the login link is built from. Must be provided — if omitted the link in the email will be malformed (`None?...`). |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |

> [!IMPORTANT]
> `root_url` is **required in practice**. The FastAPI query param accepts `null` but the email builder does not guard against it — omitting it produces a broken link in the email.

**Magic link URL format (default `key_param_name`):**
```
{root_url}?user_id={user_id_random}&auth_key={auth_key}&valid_email=True
```
The frontend at `root_url` should read these query params and call `POST /v3/action/user/authenticate` with `{ "user_id": "...", "auth_key": "..." }`. Note that `valid_email=True` is **always** injected — authenticating via a magic link automatically marks the user's email as verified.

**Response:** `data: true` on success (email sent). `404` if user not found. `500` if delivery failed — common causes: account email not configured, user `enable = false`, or `allow_auth_key = false`.

---

### F. User Lookups via V3 CRUD Search

The three legacy lookup routes (`lookup`, `lookup_email`, `lookup_username`) are replaced by standard V3 CRUD search:

```typescript
// Look up by user_id (Vision ID)
POST /v3/crud/user/search
{ "and": [{ "field": "id_random", "op": "eq", "value": "<user_id>" }] }

// Look up by email
POST /v3/crud/user/search
{ "and": [{ "field": "email", "op": "eq", "value": "user@example.com" }] }

// Look up by username
POST /v3/crud/user/search
{ "and": [{ "field": "username", "op": "eq", "value": "scott" }] }
```

Results are automatically scoped to the `x-account-id` provided in the request.

---

## 10. Event Exhibit Tracking Export (Leads Export)

Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.

- **Method:** `GET`
- **Path:** `/v3/action/event_exhibit/{exhibit_id}/tracking_export`
- **Auth:** Standard V3 headers (`x-aether-api-key` + `x-account-id` or `?jwt=`)

### Query Parameters

| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `file_type` | `CSV` \| `XLSX` | `CSV` | Output format. |
| `return_file` | bool | `true` | `true` → file download response. `false` → JSON body with row data. |

### Response

- `Content-Type: text/csv` (CSV) or `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX)
- `Content-Disposition: attachment; filename="leads_export_<timestamp>.csv"`
- If there are no tracking records, a valid file with headers only is returned (not a 404).

### Columns Returned

Fixed columns (always present), followed by any custom question columns flattened from `responses_json`:

`event_exhibit_tracking_id`, `created_on`, `updated_on`, `event_exhibit_name`, `event_badge_full_name`, `event_badge_email`, `event_badge_professional_title`, `event_badge_affiliations`, `event_badge_location`, `event_badge_country`, `external_person_id`, `exhibitor_notes`, `priority`, `enable`, `hide`, `[custom question codes…]`

> **Note:** `exhibitor_notes` has HTML tags stripped automatically for clean CSV output.

### Permission Requirement — `leads_api_access`

> [!IMPORTANT]
> This endpoint enforces a **per-exhibit permission flag**. The `event_exhibit` record **must** have `leads_api_access = true` set in the database, OR the caller must have manager-level account access (JWT with `manager: true`).
>
> If `leads_api_access` is `false` or `null` on the exhibit, the API returns:
> ```json
> { "detail": "Access denied: leads API access is not enabled for this exhibit." }
> ```
> **Fix:** Enable the flag on the exhibit record via `PATCH /v3/crud/event_exhibit/{id}` with `{ "leads_api_access": true }`, or set it directly in the database/admin panel.

#### Dual purpose of `leads_api_access`

This flag serves two related but distinct roles:

1. **3rd-party API access (original intent):** Controls whether external systems (exhibitor apps, badge-scanning devices, etc.) are permitted to push or pull lead data for this exhibit via the API.
2. **UI export gate (new):** The frontend should read `leads_api_access` from the exhibit record and use it to show or hide the export/download button. Only render the button when the flag is `true` — this prevents users from triggering a request that will always 403.

The recommended pattern is to fetch the exhibit record first and gate the UI on this field before the user ever sees the export option. The API enforces the same check server-side as a safety net.

### Example Request

```ts
const resp = await fetch(
  `https://dev-api.oneskyit.com/v3/action/event_exhibit/${exhibitId}/tracking_export?file_type=CSV&return_file=true`,
  {
    headers: {
      'x-aether-api-key': API_KEY,
      'x-account-id': accountId,
    },
  }
);
// resp is a file blob — use URL.createObjectURL() or trigger a download
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
```

---

## 11. Troubleshooting 403 Forbidden

If you receive a 403 on a valid ID:
1.  Verify `x-aether-api-key` is correct.
2.  Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
3.  Verify the record actually belongs to the account ID you are sending.
4.  Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
5.  Confirm the frontend is not treating `params.key` as an implicit bypass and stripping `x-account-id`.
6.  If list/search endpoints work but `GET /v3/crud/{obj_type}/{id}` still returns 403, this is likely endpoint-level policy (e.g., requires stronger auth like JWT) rather than a transport/header bug.
