# Blufyre Platform API Reference

The Blufyre Platform is a multi-tenant backend-as-a-service that powers multiple SaaS products. It provides shared infrastructure so product teams can focus on their domain logic rather than rebuilding auth, billing, integrations, and storage for every app.

Base URLs:
- **Dev:** `https://dev.blufyreplatform.com`
- **Prod:** `https://blufyreplatform.com`

## Architecture Overview

Each SaaS product built on the platform registers as a **tenant**. Tenants are isolated — users, data, connections, and files belonging to one tenant are never visible to another. A typical product integration looks like:

1. **Obtain a product API key** from the platform admin (see [Product API Keys](#product-api-keys))
2. **Register** a tenant (creates a tenant + admin user, returns JWT tokens)
3. **Authenticate** users via login (subdomain scopes them to their tenant)
4. **Store data** using the generic document API (no schema migration needed)
5. **Connect** to third-party systems (Salesforce, Dynamics 365, OpenAI, etc.)
6. **Send emails**, **upload files**, **schedule tasks**, and **proxy AI calls** through platform APIs
7. **Track usage** for billing via usage events

## Terminology

The platform uses four defined terms consistently:

- **Platform Admin** — The Blufyre operator. Manages products, tenants, connection types, and global configuration. Authenticates via `POST /platform/auth/login`.
- **Product** — A SaaS application registered on the platform (e.g., AI CRM). Identified by a product API key (`pk_live_...`). Products gate tenant registration and login.
- **Tenant** — A company or organization that subscribes to a product. Each tenant is fully isolated — its users, data, connections, files, and billing are invisible to other tenants. Previously referred to as "client" in some contexts.
- **User** — An individual person within a tenant. Has a role: `admin` (full access), `user` (read/write), or `readonly` (read-only). Authenticates via `POST /auth/login`.

## CORS

The API returns CORS headers on all responses:

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Api-Key, X-Product-Key
```

All origins are currently allowed. Product teams do **not** need to register their domains.

> **Note:** If your frontend makes requests that trigger a browser preflight (e.g., `Content-Type: application/json` with `Authorization` header), ensure your requests go through a proxy or that your integration handles the OPTIONS preflight correctly. See [Frontend Integration Patterns](#frontend-integration-patterns) below.

## Frontend Integration Patterns

Product teams building browser-based frontends (React, Vue, etc.) hosted on a separate domain from the API will need to proxy API requests through their CDN or web server. While the API returns permissive CORS headers, routing through your own domain simplifies authentication flows, avoids mixed-domain cookie issues, and gives you control over caching and error handling.

### Direct requests (same domain)

If your frontend is served from the same domain as the API (e.g., a subdirectory or subdomain you control), direct API requests will work with no additional setup.

### CloudFront proxy (recommended for S3-hosted SPAs)

If your frontend is hosted on S3 + CloudFront on its own domain (e.g., `yourproduct.com`), proxy API traffic through CloudFront:

1. **Add the API as a custom origin** in your CloudFront distribution:
   - Origin domain: `blufyreplatform.com` (prod) or `dev.blufyreplatform.com` (dev)
   - Protocol: HTTPS only

2. **Create a cache behavior** for your API path (e.g., `/api/*`) targeting that origin:
   - **Origin request policy:** `AllViewerExceptHostHeader` — this forwards all headers except `Host`, which prevents the API from receiving your frontend's hostname
   - **Cache policy:** `CachingDisabled` — API responses should not be cached by CloudFront
   - Use a CloudFront Function to strip the path prefix (e.g., rewrite `/api/health` → `/health`)

3. **Your frontend** makes requests to its own domain (e.g., `https://yourproduct.com/api/health`), and CloudFront forwards them to the platform API transparently.

> **Important:** Do **not** forward the viewer's `Host` header to the API origin. The API does not validate the `Host` header, but API Gateway requires the `Host` to match its configured domain to route correctly. Use the `AllViewerExceptHostHeader` origin request policy in CloudFront.

### Other reverse proxies (Nginx, etc.)

If you use Nginx or another reverse proxy, apply the same principle: proxy API requests to the platform and ensure the `Host` header sent to the API matches `blufyreplatform.com` (or `dev.blufyreplatform.com`), not your frontend's domain.

## Tenant Identification

Tenants are identified by the `subdomain` field passed in request bodies (e.g., during `/register` and `/auth/login`). This is a logical identifier — it is **not** used for DNS-based routing. All API traffic goes through the single base URL (`blufyreplatform.com` for prod, `dev.blufyreplatform.com` for dev) regardless of which tenant is being accessed.

There are no tenant-specific subdomains like `acme.blufyreplatform.com`. Product teams that want tenant-specific domains or subdomains for their own product (e.g., `acme.yourproduct.com`) should handle that mapping in their own frontend/routing layer and pass the corresponding `subdomain` value to the platform API.

## Product API Keys

Every SaaS product built on the platform is assigned a **product API key** that must be included with registration and login requests. This key identifies which product a user is signing up for or logging into, and is used to enforce product-level access control.

**To obtain a product API key:** Contact the Blufyre platform admin. The admin will register your product in the platform and provide you with an API key in the format `pk_live_<64 hex chars>`. Each product gets its own key.

**How to include the key:** Pass the key in one of the following ways (checked in this order):

1. **Request header (recommended):** `X-Product-Key: pk_live_abc123...`
2. **Request header (alternative):** `X-Api-Key: pk_live_abc123...`
3. **Query parameter:** `?api_key=pk_live_abc123...`
4. **Request body field:** `"api_key": "pk_live_abc123..."`

If the key is missing or invalid, the API returns `401` with:
```json
{
  "error": {
    "code": "AUTHENTICATION_ERROR",
    "message": "Product API key required. Include 'api_key' in the request body, or set the X-Product-Key header."
  }
}
```

> **Important:** Keep your product API key secret. Do not expose it in frontend code. Your backend should inject the key into API requests on behalf of your frontend. If a key is compromised, contact the platform admin to rotate it.

## Authentication

All authenticated endpoints require:
```
Authorization: Bearer <access_token>
```

Access tokens expire after 15 minutes. Use the refresh endpoint to get a new one.

### Roles
- `admin` — Full access within tenant
- `user` — Read/write access to standard operations
- `readonly` — Read-only access

## Error Handling

### Error Format
All errors return:
```json
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message"
  }
}
```

### Error Codes

| Code | HTTP Status | Description |
|------|-------------|-------------|
| `VALIDATION_ERROR` | 400 | Request body or parameters failed validation |
| `AUTHENTICATION_ERROR` | 401 | Missing, expired, or invalid access token |
| `AUTHORIZATION_ERROR` | 403 | Authenticated user lacks the required role for this operation |
| `NOT_FOUND` | 404 | Requested resource does not exist or belongs to a different tenant |
| `CONFLICT` | 409 | Resource already exists (e.g., duplicate email or subdomain) |
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests — see [Rate Limiting](#rate-limiting) |
| `INTERNAL_ERROR` | 500 | Unexpected server error |

### Rate Limiting

The API enforces rate limiting at two levels. When a limit is exceeded, the API returns HTTP 429 (error code `RATE_LIMIT_EXCEEDED` for the per-product limit; the gateway limit returns a 429 as well).

**Global gateway limit** (per source IP, all endpoints):
- **Steady-state rate:** 100 requests per second
- **Burst limit:** 200 requests

**Per-product limits** (apply to product-API-key traffic):
- **Default:** 60 requests per minute per product.
- **`GET /platform/slack/events` (background drain poll):** capped tighter at **1 request/second (burst 3)**. This endpoint is designed to be polled on an interval, not in a tight loop — pace your worker at roughly one poll per second or slower. See [Product-Scoped Polling](#product-scoped-polling-for-background-workers).

Rate limits are subject to change. If your product requires higher throughput, contact the platform team.

---

## Auth Endpoints

Use these to onboard new customers, log users in, and maintain sessions. Every SaaS product needs to call login at minimum — the returned JWT is required for all other API calls. The subdomain in the login request scopes the user to their tenant, so multiple companies can use the same product without seeing each other's data.

> **Note:** The Register and Login endpoints require a product API key. See [Product API Keys](#product-api-keys) for how to obtain and include one.

### Register Tenant
`POST /register` — Requires [product API key](#product-api-keys)

Creates a new tenant with an initial admin user. Use this when a new company signs up for your SaaS product. It provisions an isolated tenant, creates the first admin user, auto-assigns your product to the tenant, and returns tokens so the user can immediately start using the app.

**Request:**

Include your product API key via header or body (see [Product API Keys](#product-api-keys)).

```json
{
  "company_name": "string (1-200 chars, required)",
  "subdomain": "string (3-63 chars, lowercase alphanumeric + hyphens, required)",
  "admin_email": "string (valid email, required)",
  "admin_password": "string (8-128 chars, required)",
  "admin_first_name": "string (1-100 chars, required)",
  "admin_last_name": "string (1-100 chars, required)"
}
```

Reserved subdomains: api, www, admin, platform, app, mail, smtp, ftp, dev, staging, test

**Response (201):**
```json
{
  "tenant_id": "string (uuid)",
  "subdomain": "string",
  "product_id": "string (the product associated with this registration)",
  "admin_user": {
    "user_id": "string (uuid)",
    "email": "string",
    "first_name": "string",
    "last_name": "string",
    "role": "admin"
  },
  "access_token": "string (JWT)",
  "refresh_token": "string (JWT)"
}
```

### Login
`POST /auth/login` — Requires [product API key](#product-api-keys)

Authenticates a user within their tenant. The subdomain identifies which company they belong to. Use this on your product's login page. Store the access token in memory and the refresh token for session persistence.

**Request:**

Include your product API key via header or body (see [Product API Keys](#product-api-keys)).

```json
{
  "subdomain": "string (required)",
  "email": "string (required)",
  "password": "string (required)"
}
```

**Response (200):**
```json
{
  "access_token": "string (JWT, 15-min expiry)",
  "refresh_token": "string (JWT, 7-day expiry)",
  "user_id": "string (uuid)",
  "tenant_id": "string (uuid)",
  "roles": ["admin"]
}
```

### Refresh Token
`POST /auth/refresh` — No auth required

Exchange a refresh token for a new access token. Call this when an API request returns 401 (access token expired). If the refresh also fails, redirect the user to login. This keeps sessions alive for up to 7 days without re-entering credentials.

**Request:**
```json
{
  "refresh_token": "string (required)"
}
```

**Response (200):**
```json
{
  "access_token": "string (JWT)",
  "refresh_token": "string (JWT)"
}
```

---

## Users

Manage the people within a tenant who can access your product. Use these to build team management, user settings, and admin panels. Each user has a role (admin, user, readonly) that controls what they can do across all platform APIs. Users are scoped to their tenant — an admin in Company A cannot see or manage users in Company B.

All endpoints require `Authorization: Bearer <token>`. Operations are scoped to the authenticated tenant.

### List Users
`GET /users` — Roles: admin, user, readonly

**Response (200):**
```json
{
  "users": [
    {
      "user_id": "string",
      "email": "string",
      "first_name": "string",
      "last_name": "string",
      "role": "admin | user | readonly",
      "status": "active | suspended",
      "created_at": "ISO 8601 | null"
    }
  ],
  "count": 0
}
```

### Get User
`GET /users/{user_id}` — Roles: admin, user, readonly

**Response (200):** Same shape as single user object above.

### Create User
`POST /users` — Roles: admin

**Request:**
```json
{
  "email": "string (valid email, required)",
  "password": "string (8-128 chars, required)",
  "first_name": "string (1-100 chars, required)",
  "last_name": "string (1-100 chars, required)",
  "role": "string (optional, default: 'user', one of: admin, user, readonly)"
}
```

**Response (201):** Single user object.

### Update User
`PUT /users/{user_id}` — Roles: admin

**Request (all fields optional):**
```json
{
  "first_name": "string | null",
  "last_name": "string | null",
  "role": "admin | user | readonly | null",
  "status": "string | null"
}
```

**Response (200):** Single user object.

### Delete User
`DELETE /users/{user_id}` — Roles: admin

**Response (200):**
```json
{ "deleted": true }
```

---

## Connections

Manage third-party integrations per tenant. Connections let each customer link their own Salesforce, Dynamics 365, Smartsheet, or OpenAI accounts to your product. Credentials are encrypted with per-tenant KMS keys — even platform admins cannot decrypt another tenant's secrets.

**Typical use cases:**
- CRM sync: connect a customer's Salesforce or Dynamics 365 to pull/push contacts, opportunities, etc.
- AI features: each tenant can bring their own OpenAI API key, or use the platform's shared key
- Data import/export: connect to Smartsheet or other data sources for ETL workflows
- The connection type registry defines which integrations are available; OAuth flows handle the auth handshake

### List Connections
`GET /connections` — Roles: admin, user, readonly

**Response (200):**
```json
{
  "connections": [
    {
      "connection_id": "string",
      "tenant_id": "string",
      "type_id": "string (e.g. salesforce, dynamics365, openai)",
      "name": "string",
      "auth_method": "api_key | oauth2",
      "status": "pending_auth | active | error",
      "metadata": {},
      "last_tested_at": "ISO 8601 | null",
      "last_test_result": "string | null",
      "created_at": "ISO 8601 | null",
      "updated_at": "ISO 8601 | null"
    }
  ],
  "count": 0
}
```

### Get Connection
`GET /connections/{connection_id}` — Roles: admin, user, readonly

**Response (200):** Single connection object.

### Create Connection
`POST /connections` — Roles: admin

Creates a new connection. OAuth types start in `pending_auth` and must then go through `POST /connections/{connection_id}/oauth/initiate` to authorize against the third-party provider. `api_key` types (credentials supplied inline) and `username_password` types (credentials exchanged for a token during creation) come back `active` immediately — no authorize step.

**Request:**
```json
{
  "name": "string (1-200 chars, required)",
  "type_id": "string (1-50 chars, required) — must be a registered connection type",
  "auth_method": "api_key | oauth2 | username_password (optional)",
  "metadata": { "provider-specific config (see below)" },
  "credentials": { "oauth_client_id + oauth_client_secret for OAuth, api_key for API-key types, or oauth_client_id + username + password for username_password" }
}
```

`type_id` must match a type registered in `GET /connection-types`. Requesting an unregistered type returns `404 NOT_FOUND`.

Credentials are encrypted with the tenant's KMS key before storage. Platform admins cannot decrypt them.

**Per-connection `auth_method` override:** the connection_type carries a default `auth_method` (e.g., `salesforce` defaults to `oauth2`). Supplying `auth_method` on the request overrides the default for *this* connection only. The override must be one of the type's `supported_auth_methods` (see `GET /connection-types`). Allowed values today: `api_key`, `oauth2`, `username_password`.

**Note:** `credentials.oauth_client_id` and `credentials.oauth_client_secret` are **optional** for OAuth connection types when your platform admin has registered a product-level OAuth client for that type (see [Product OAuth Client Registration](#product-oauth-client-registration)). If omitted, the platform falls back to the product-level client and tenants do not need to bring their own third-party provider app. If you include them on the connection, they act as a per-connection override and take precedence over the product-level client.

**Response (201):** Single connection object (credentials NOT returned).

#### Provider-specific metadata and credentials shapes

Different providers expect different fields in the `metadata` and `credentials` objects. The shapes below are authoritative:

##### Salesforce (`type_id: "salesforce"`)

OAuth 2.0 Web Server Flow against Salesforce production (`login.salesforce.com`) or sandbox (`test.salesforce.com`). The target is selected per connection via the `metadata.environment` field — **set this at connection creation time; it cannot be changed later without deleting and recreating the connection.**

Each customer creates their own Connected App / External Client App in their Salesforce org and supplies its consumer key + consumer secret on the connection. This is the only supported auth flow for Salesforce.

> **Note on JWT Bearer / package distribution**: an earlier version of the platform supported a JWT Bearer flow backed by a Blufyre-published unmanaged Salesforce package. That flow was removed because Salesforce's External Client App framework regenerates consumer keys per subscriber org during unmanaged package installs — the customer's installed ECA ends up with a different consumer key than the source, so platform-signed JWTs are rejected with `app_not_found`. Customers now BYO their own Connected App via the standard OAuth flow below.

```json
{
  "name": "Acme Prod Salesforce",
  "type_id": "salesforce",
  "metadata": {
    "environment": "production | sandbox (defaults to production if omitted)"
  },
  "credentials": {
    "oauth_client_id": "consumer key (optional — see fallback below)",
    "oauth_client_secret": "consumer secret (optional — see fallback below)"
  }
}
```

Setup flow:
1. Customer creates a Connected App / External Client App in their Salesforce org with callback URL `<api-base>/connections/oauth/callback`.
2. `POST /connections` (above shape). Status: `pending_auth`.
3. `POST /connections/{connection_id}/oauth/initiate` → returns an authorize URL. Customer's browser visits it, clicks Allow.
4. Salesforce redirects back to `/connections/oauth/callback`, the platform exchanges the code for tokens, status flips to `active`.
5. Background job refreshes the access token every 30 minutes using the stored `refresh_token`.

**Product-level fallback (omit `credentials` entirely):** if a SaaS product has registered a product-level OAuth client for `salesforce` (see [Product OAuth Client Registration](#product-oauth-client-registration)), leave `credentials` empty (`{}`) and the platform will use the product's shared Connected App. Supply `credentials.oauth_client_id`/`oauth_client_secret` only when a tenant wants to override the shared app with their own.

After OAuth completes, the platform stores these additional fields in the encrypted credential blob (auto-populated from Salesforce):
- `access_token` — short-lived (~2 hours); auto-refreshed by the platform
- `refresh_token` — long-lived; used by the 30-minute background refresh job
- `instance_url` — the tenant's specific Salesforce org URL (e.g., `https://acme.my.salesforce.com`); used by the `/salesforce/{connection_id}/*` proxy endpoints
- `issued_at`, `signature`, `id` — informational Salesforce fields

**Sandbox support:** setting `metadata.environment` to `"sandbox"` routes OAuth authorize, token exchange, and refresh to `test.salesforce.com` instead of `login.salesforce.com`. The customer's Connected App callback URL and scopes (`api refresh_token`) are the same.

**Connecting multiple Salesforce orgs per tenant:** create one connection per org. A single tenant can have any number of simultaneously-active Salesforce connections. Products reference them by `connection_id` when calling `/salesforce/{connection_id}/query`.

##### Dynamics 365 (`type_id: "dynamics365"`)

Azure AD OAuth 2.0 against Microsoft's identity platform.

```json
{
  "name": "Acme Dynamics",
  "type_id": "dynamics365",
  "metadata": {
    "resource_url": "https://<your-org>.crm.dynamics.com (required — used by test and future API calls)",
    "azure_tenant_id": "string (optional, default 'common') — Azure AD tenant ID for the OAuth authority"
  },
  "credentials": {
    "oauth_client_id": "Azure AD app registration client ID (required)",
    "oauth_client_secret": "Azure AD app registration client secret (required)"
  }
}
```

Default OAuth scopes: `https://dynamics.microsoft.com/.default offline_access`. Override via the connection type's `oauth_config.scopes`.

##### Smartsheet (`type_id: "smartsheet"`)

Smartsheet OAuth 2.0.

```json
{
  "name": "Acme Smartsheet",
  "type_id": "smartsheet",
  "metadata": {},
  "credentials": {
    "oauth_client_id": "Smartsheet app client ID (optional — see below)",
    "oauth_client_secret": "Smartsheet app client secret (optional — see below)"
  }
}
```

**Product-level fallback:** if your platform admin has registered a product-level OAuth client for `smartsheet`, leave `credentials` empty and the platform will use the product's shared app.

Default OAuth scopes: `READ_SHEETS WRITE_SHEETS`. Override via the connection type's `oauth_config.scopes` to include `READ_USERS`, `SHARE_SHEETS`, etc.

**Alternative: personal API access token (`auth_method: "api_key"`)** — Smartsheet users can generate a long-lived **API Access Token** in their account (Account → Personal Settings → API Access → *Generate new access token*) and connect with that directly, skipping OAuth entirely. Good for customers who'd rather paste a token than authorize an app. Create the connection in a single call — it comes back `active` immediately (no `/oauth/initiate` step):

```json
{
  "name": "Acme Smartsheet",
  "type_id": "smartsheet",
  "auth_method": "api_key",
  "credentials": {
    "api_key": "the user's Smartsheet API access token (required)"
  }
}
```

Notes:
- The token is used directly as `Authorization: Bearer <token>` — the same way the proxy uses OAuth tokens. No client ID/secret and no product-level OAuth client are involved.
- Smartsheet API access tokens **do not expire by default** (so there's nothing to refresh), unless the customer's Smartsheet System Admin has configured token expiration — in which case the token is revoked at expiry and the customer must generate a new one and update the connection's credentials.
- If the customer's account uses SSO, the token is generated from their Smartsheet account settings as usual; the token itself carries the access.
- You may supply the token under `credentials.access_token` instead of `credentials.api_key` — both are accepted.

##### Slack (`type_id: "slack"`)

Slack OAuth 2.0 v2 bot token flow. The connection represents a Slack workspace installation.

```json
{
  "name": "Acme Slack",
  "type_id": "slack",
  "metadata": {},
  "credentials": {
    "oauth_client_id": "Slack app client ID (optional — see below)",
    "oauth_client_secret": "Slack app client secret (optional — see below)"
  }
}
```

**Product-level fallback:** if your platform admin has registered a product-level OAuth client for `slack`, leave `credentials` empty and the platform will use the product's shared Slack app.

After OAuth completes, the platform extracts the `team.id` from Slack's token response and creates a team → connection mapping so inbound Slack events (`POST /slack/events`) can be routed back to this tenant.

Default bot scopes: `chat:write channels:read groups:read users:read users:read.email files:write reactions:write`.

##### OpenAI (`type_id: "openai"`)

No OAuth — direct API key.

```json
{
  "name": "Acme OpenAI",
  "type_id": "openai",
  "metadata": {},
  "credentials": {
    "api_key": "sk-proj-... (required)"
  }
}
```

Since there's no OAuth handshake, OpenAI connections go directly to `active` status on creation if a valid `api_key` is supplied.

##### Egnyte (`type_id: "egnyte"`)

OAuth 2.0 Web Server Flow against the customer's per-tenant Egnyte subdomain. Every Egnyte customer has their own host: `https://{subdomain}.egnyte.com`. The connection's `metadata.subdomain` is the single source of truth for which tenant this connection talks to — **set it at connection creation time; it cannot be changed later without deleting and recreating the connection.**

```json
{
  "name": "Acme Egnyte",
  "type_id": "egnyte",
  "metadata": {
    "subdomain": "acme (required — the part before .egnyte.com)"
  },
  "credentials": {
    "oauth_client_id": "Egnyte app API key (optional — see fallback)",
    "oauth_client_secret": "Egnyte app secret (optional — see fallback)"
  }
}
```

**Your responsibility (SaaS product side):**

1. **Collect the subdomain from your customer.** Your "connect Egnyte" UI needs a text field. Tell your customer: *"What's your Egnyte subdomain? It's the part before `.egnyte.com` in the URL you use to log in. For example, if you log in at https://acme.egnyte.com, your subdomain is `acme`."* Strip whitespace, lowercase, drop any protocol/path the user might paste — the platform will accept lowercase letters, numbers, and hyphens only.
2. **Send the subdomain in `metadata.subdomain`** on `POST /connections`. This is **required** — the platform has no way to discover it. The string must be the subdomain alone, not the full URL.
3. **Trigger the OAuth flow** with `POST /connections/{connection_id}/oauth/initiate`. The platform builds the authorize URL using `metadata.subdomain` and returns it; redirect the customer's browser there. They click Allow, Egnyte redirects back to the platform's callback, and the connection flips to `active`.
4. **Catch the common mistakes** in your UI before submitting:
   - Customer pastes `https://acme.egnyte.com` instead of `acme` → strip protocol + `.egnyte.com`
   - Customer pastes a vanity domain (e.g. `files.acme.com` that DNS-aliases to `acme.egnyte.com`) → the platform won't follow the alias. Surface this in your UI and ask for the canonical `*.egnyte.com` subdomain.
   - Customer enters uppercase → lowercase it (the platform also lowercases internally; matching avoids client-side confusion).

**One-time setup that's NOT your customer's job** (you or your platform admin handle it once):
- Register an Egnyte app at the [Egnyte developer portal](https://developers.egnyte.com/member/register). Default scope is `Egnyte.filesystem` (folder traversal + file read/write). Add `Egnyte.link`, `Egnyte.user`, etc. as you need them.
- Configure the app's callback URL to `<api-base>/connections/oauth/callback`.
- Register the app's API key + secret as a product-level OAuth client (`POST /oauth-clients` with your product API key). All your tenants then fall back to this shared app — they don't need to bring their own. If you'd rather have each tenant bring their own app, omit the product-level registration and require `credentials.oauth_client_id` + `credentials.oauth_client_secret` on each connection.

After connection creation + OAuth completion, the background refresh job keeps the access token fresh every 30 minutes. Egnyte rotates refresh tokens on each refresh — the platform stores the new one automatically.

Once active, use the [Egnyte API Proxy & Tools](#egnyte-api-proxy--tools) section for folder traversal and file upload/download.

**Alternative: username/password (`auth_method: "username_password"`)** — for customers who can't authorize an installed/public app (admin restrictions) but *can* obtain their own Egnyte API key. This uses Egnyte's "internal application" Resource Owner Password Credentials grant: the user's Egnyte login is exchanged for a token directly, with no browser redirect. Create the connection in a single call — it comes back `active` immediately (no `/oauth/initiate` step):

```json
{
  "name": "Acme Egnyte",
  "type_id": "egnyte",
  "auth_method": "username_password",
  "metadata": {
    "subdomain": "acme (required)",
    "remember_credentials": false
  },
  "credentials": {
    "oauth_client_id": "the customer's own Egnyte API key (required)",
    "oauth_client_secret": "optional",
    "username": "the customer's Egnyte login (required)",
    "password": "the customer's Egnyte password (required)"
  }
}
```

Important constraints — surface these in your UI:
- **Requires the customer's *own* Egnyte API key.** Egnyte rejects third-party/public developer keys for this grant, so the product-level OAuth client does **not** apply here — each connection must supply `credentials.oauth_client_id`.
- **No refresh token; the token lasts ~30 days.** Set `metadata.remember_credentials: true` to have the platform store the login (KMS-encrypted) and auto-renew before expiry. Leave it `false` (default) to store only the token — when it expires the connection stops working and must be re-created. Choose per your security posture.
- **Won't work for SSO/SAML or MFA-protected accounts** — there's no password to exchange (or MFA blocks it). Those customers must use the OAuth flow instead.
- The password is never logged and (when stored) is encrypted with the tenant's KMS key like all other credentials.

### Update Connection
`PUT /connections/{connection_id}` — Roles: admin

**Request (all fields optional):**
```json
{
  "name": "string | null",
  "metadata": "object | null"
}
```

**Response (200):** Single connection object.

### Delete Connection
`DELETE /connections/{connection_id}` — Roles: admin

**Response (200):**
```json
{ "deleted": true }
```

### Test Connection
`POST /connections/{connection_id}/test` — Roles: admin

Tests connectivity to the third-party service.

**Response (200):**
```json
{
  "connection_id": "string",
  "status": "success | failed",
  "message": "string",
  "tested_at": "ISO 8601"
}
```

### Refresh OAuth Token
`POST /connections/{connection_id}/refresh` — Roles: admin

Forces an immediate refresh of an OAuth connection's `access_token` using the stored `refresh_token`. Useful when:
- The platform's scheduled 30-minute background refresh has not yet caught up
- The provider's `access_token` lifetime is shorter than the scheduled refresh interval
- Diagnosing auth issues without triggering a full re-authorization flow

The connection must have `auth_method = "oauth2"` and a stored `refresh_token` (i.e. it must have been fully authorized at least once). Connections with only an expired refresh token will fail — in that case the tenant admin must re-run `/connections/{connection_id}/oauth/initiate`.

**Response (200):**
```json
{
  "status": "refreshed",
  "connection_id": "string",
  "last_refreshed_at": "ISO 8601"
}
```

**Errors:**
- `400 VALIDATION_ERROR` — connection is not OAuth, or has no refresh_token / OAuth client configured
- `404 NOT_FOUND` — connection does not exist for this tenant

> **Note:** The Salesforce proxy endpoints (`/salesforce/{connection_id}/request`, `/query`, `/objects`, `/describe/{sobject}`, `/composite`) also perform **transparent refresh-on-401** — if Salesforce returns 401 `INVALID_SESSION_ID`, the platform automatically refreshes the token and retries the request once. Products generally do not need to call `/refresh` explicitly for Salesforce. Other providers (Dynamics 365, Smartsheet, Slack) do not yet have reactive refresh and rely on the 30-minute scheduled refresh; use `/refresh` as a manual escape hatch.

### List Connection Types
`GET /connection-types` — Roles: admin, user, readonly

Returns the registry of available integration types.

**Response (200):**
```json
{
  "connection_types": [
    {
      "type_id": "string",
      "display_name": "string",
      "auth_method": "api_key | oauth2 | username_password (the type's default; can be overridden per connection at create time)",
      "supported_auth_methods": ["oauth2", "username_password"]
    }
  ],
  "count": 0
}
```

- `auth_method` is the type's **default** — what a `POST /connections` will use if you don't supply `auth_method` in the request body.
- `supported_auth_methods` is the **full set** the provider implements. Some types advertise a single value (Salesforce/Slack/Dynamics: `["oauth2"]`; OpenAI: `["api_key"]`), but several support more than one — **Egnyte** advertises `["oauth2", "username_password"]` and **Smartsheet** advertises `["oauth2", "api_key"]` (OAuth or a personal API access token). Read this field to discover which `auth_method` values a type accepts rather than assuming a single one.

### Product OAuth Client Registration

To let tenants create OAuth connections without requiring each of them to bring their own provider credentials, ONE OAuth client is registered per (product, provider type) combination. Tenant connections without an explicit `oauth_client_id` automatically use the product-level client.

There are two ways to register a product OAuth client — both write to the same underlying storage:

- **Option A: Platform admin does it on your behalf** — use this for first-time setup done via an ops ticket, for debugging, or if the product team prefers not to hold a product API key with write access inside a CI/CD pipeline. Requires a platform admin JWT. Endpoints live under `/platform/saas-products/{product_id}/oauth-clients`.
- **Option B: Product self-service (recommended)** — the product uses its own API key (`X-Product-Key` header) to register, list, rotate, and delete its OAuth clients. No platform admin involvement. The `product_id` is inferred from the validated API key and does NOT appear in the URL. Endpoints live under `/oauth-clients`.

**Resolution order when a tenant starts an OAuth flow:**
1. If the connection's `credentials.oauth_client_id` / `credentials.oauth_client_secret` are set, they are used (per-connection override).
2. Otherwise, the platform looks up the tenant's product assignments and uses the first product that has a registered OAuth client for this `type_id`.
3. Otherwise, the OAuth initiate endpoint returns an error directing the caller to register a product-level OAuth client.

**Callback URL:** whichever OAuth client you register, configure its callback URL in the third-party provider's app dashboard to `<api-base>/connections/oauth/callback`. This must be whitelisted in the provider's app before any tenant attempts to authorize.

**Secret storage:** the `oauth_client_secret` is encrypted at rest with the platform KMS key before being stored in DynamoDB. None of the GET endpoints ever return the plaintext secret. To rotate it, simply POST a new registration with the same `type_id` — it replaces the existing record.

#### Option A — Platform admin endpoints

#### Register a product OAuth client

`POST /platform/saas-products/{product_id}/oauth-clients` — Roles: platform_admin

**Request:**
```json
{
  "type_id": "salesforce",
  "oauth_client_id": "3MVG9...Connected App consumer key",
  "oauth_client_secret": "your Connected App consumer secret",
  "scopes": ["api", "refresh_token"],
  "callback_url": "https://<api-base>/connections/oauth/callback"
}
```

- `type_id` (required) — must match a registered connection type with `auth_method: "oauth2"`.
- `oauth_client_id` (required) — the third-party app's consumer key.
- `oauth_client_secret` (required) — encrypted at rest, never returned.
- `scopes` (optional) — if set, overrides the connection type's default OAuth scopes for this product.
- `callback_url` (optional, informational) — tells humans what URL to whitelist in the provider's app. The platform always uses `<api-base>/connections/oauth/callback` at OAuth time.

**Response (201):**
```json
{
  "product_id": "vybeset",
  "type_id": "salesforce",
  "oauth_client_id": "3MVG9...",
  "scopes": ["api", "refresh_token"],
  "callback_url": "https://.../connections/oauth/callback",
  "created_at": "2026-04-14T17:00:00+00:00",
  "updated_at": "2026-04-14T17:00:00+00:00",
  "created_by": "admin-user-id"
}
```

Errors:
- `400` — product does not exist, connection type does not exist, or request body invalid.

#### List product OAuth clients

`GET /platform/saas-products/{product_id}/oauth-clients` — Roles: platform_admin

**Response (200):**
```json
{
  "oauth_clients": [
    {
      "product_id": "vybeset",
      "type_id": "salesforce",
      "oauth_client_id": "3MVG9...",
      "scopes": ["api", "refresh_token"],
      "callback_url": "https://...",
      "created_at": "...",
      "updated_at": "...",
      "created_by": "admin-user-id"
    }
  ],
  "count": 1
}
```

Secrets are never returned.

#### Get a single product OAuth client

`GET /platform/saas-products/{product_id}/oauth-clients/{type_id}` — Roles: platform_admin

**Response (200):** same shape as one element of the list response above. Secret is never returned.

Errors:
- `404` — no OAuth client registered for this product + type.

#### Delete a product OAuth client

`DELETE /platform/saas-products/{product_id}/oauth-clients/{type_id}` — Roles: platform_admin

**Response (200):**
```json
{ "deleted": true, "product_id": "vybeset", "type_id": "salesforce" }
```

Errors:
- `404` — no OAuth client registered for this product + type.

After deletion, tenant connections of this type that relied on the product-level client will fail at OAuth time unless they either (a) supply their own `oauth_client_id`/`oauth_client_secret` per-connection, or (b) the admin registers a replacement product-level client.

#### Option B — Product self-service endpoints

These endpoints let a SaaS product manage its OWN OAuth client credentials using its product API key — no platform admin involvement. All four endpoints authenticate via the `X-Product-Key` header (or `api_key` in the request body / query string). The calling product is identified by that key; there is NO `{product_id}` placeholder in the URL.

##### Register / rotate a product OAuth client (self-service)

`POST /oauth-clients` — Auth: `X-Product-Key`

**Request:**
```json
{
  "type_id": "salesforce",
  "oauth_client_id": "3MVG9...Connected App consumer key",
  "oauth_client_secret": "your Connected App consumer secret",
  "scopes": ["api", "refresh_token"],
  "callback_url": "https://<api-base>/connections/oauth/callback"
}
```

POSTing again with the same `type_id` replaces the existing record — this is how you rotate the client secret.

**Response (201):**
```json
{
  "product_id": "vybeset",
  "type_id": "salesforce",
  "oauth_client_id": "3MVG9...",
  "scopes": ["api", "refresh_token"],
  "callback_url": "https://.../connections/oauth/callback",
  "created_at": "2026-04-14T17:00:00+00:00",
  "updated_at": "2026-04-14T17:00:00+00:00",
  "created_by": "product:vybeset"
}
```

Errors:
- `400` — unknown `type_id`, or request body invalid.
- `401` — missing or invalid product API key.

##### List the calling product's OAuth clients

`GET /oauth-clients` — Auth: `X-Product-Key`

**Response (200):**
```json
{
  "oauth_clients": [
    {
      "product_id": "vybeset",
      "type_id": "salesforce",
      "oauth_client_id": "3MVG9...",
      "scopes": ["api", "refresh_token"],
      "callback_url": "https://...",
      "created_at": "...",
      "updated_at": "...",
      "created_by": "product:vybeset"
    }
  ],
  "count": 1
}
```

Only the calling product's clients are returned — products are strictly isolated. Secrets are never returned.

##### Get a single OAuth client (self-service)

`GET /oauth-clients/{type_id}` — Auth: `X-Product-Key`

**Response (200):** same shape as one element of the list response above. Secret is never returned.

Errors:
- `404` — no OAuth client registered for the calling product + type.

##### Delete a single OAuth client (self-service)

`DELETE /oauth-clients/{type_id}` — Auth: `X-Product-Key`

**Response (200):**
```json
{ "deleted": true }
```

Errors:
- `404` — no OAuth client registered for the calling product + type.

### Initiate OAuth Flow
`POST /connections/{connection_id}/oauth/initiate` — Roles: admin

Starts an OAuth 2.0 authorization flow for an existing `pending_auth` connection. Returns a URL the tenant admin should visit in their browser to grant access to the third-party provider (Salesforce, Dynamics 365, Smartsheet, Slack).

The `connection_id` goes in the URL path. The request body is empty (`{}`). Before calling this, the connection must already exist via `POST /connections` with `oauth_client_id` and `oauth_client_secret` in the `credentials` field.

**Request:** empty body (`{}`)

**Response (200):**
```json
{
  "authorize_url": "https://login.salesforce.com/services/oauth2/authorize?response_type=code&client_id=...&state=..."
}
```

**Flow:**
1. Product creates a connection: `POST /connections` with `type_id`, `name`, `metadata`, and `credentials.oauth_client_id` + `credentials.oauth_client_secret`. Response includes `connection_id`, status is `pending_auth`.
2. Product calls `POST /connections/{connection_id}/oauth/initiate`. Platform generates a CSRF state token, stores it, and returns `authorize_url`.
3. Product redirects the tenant admin's browser to `authorize_url`. User approves access at the provider.
4. Provider redirects back to `<api-base>/connections/oauth/callback?code=...&state=...`. Platform validates the state, exchanges the code for tokens, encrypts and stores them, and sets the connection to `active`.
5. Product can now use the connection via provider-specific endpoints (e.g., `/salesforce/{connection_id}/query`).

`GET /connections/oauth/callback` — No auth (the OAuth provider redirects here)

Public endpoint. Handles the redirect from the third-party OAuth provider, validates the state token, exchanges the authorization code for access/refresh tokens, and stores them encrypted. Products don't call this directly — it's the callback URL registered in the third-party provider's app settings.

---

## Data (Generic Document Storage)

A schemaless document store for persisting any structured data your product needs. Data is organized into named collections (like "contacts", "projects", "settings") and each document is a free-form JSON object. No migrations needed — just write and read.

**Typical use cases:**
- Store product-specific domain objects (leads, tickets, invoices, etc.) without building a custom database
- App settings and configuration per tenant
- Caching external API responses
- Any structured data that doesn't fit into the other specialized APIs

Each tenant's data is fully isolated. Pagination is supported for large collections.

### List Documents
`GET /data/collections/{collection}` — Roles: admin, user, readonly

**Path Parameters:**
- `collection` — alphanumeric, hyphens, underscores (1-100 chars)

**Query Parameters:**
- `limit` — int (optional, default 50, max 100)
- `last_key` — string, base64-encoded (optional, for pagination)

**Response (200):**
```json
{
  "documents": [
    {
      "doc_id": "string",
      "collection": "string",
      "data": {},
      "created_at": "ISO 8601 | null",
      "updated_at": "ISO 8601 | null",
      "created_by": "string | null"
    }
  ],
  "count": 0,
  "next_key": "string | null"
}
```

### Get Document
`GET /data/collections/{collection}/{doc_id}` — Roles: admin, user, readonly

**Response (200):** Single document object.

### Create Document
`POST /data/collections/{collection}` — Roles: admin, user

**Request:**
```json
{
  "data": { "any": "valid JSON object (required)" }
}
```

**Response (201):** Single document object.

### Update Document
`PUT /data/collections/{collection}/{doc_id}` — Roles: admin, user

**Request:**
```json
{
  "data": { "any": "valid JSON object (required, replaces existing data)" }
}
```

**Response (200):** Single document object.

### Delete Document
`DELETE /data/collections/{collection}/{doc_id}` — Roles: admin

**Response (200):**
```json
{ "deleted": true }
```

---

## AI (LLM Proxy)

Proxies LLM requests to OpenAI through the platform's managed connection so your product doesn't need to handle API keys or rate limits directly. Usage (token counts) is tracked per-tenant for billing and monitoring.

**Typical use cases:**
- Add AI chat, summarization, or analysis features to your product
- Generate content, extract data from text, or classify inputs
- Build AI assistants scoped to each customer's data
- Track per-tenant AI costs for usage-based billing

### Chat
`POST /ai/chat` — Roles: admin, user

**Request:**
```json
{
  "model": "string (optional, default: 'gpt-4')",
  "messages": [
    { "role": "system | user | assistant", "content": "string" }
  ],
  "max_tokens": 1024,
  "temperature": 0.7
}
```

- `messages` — required, min 1 message
- `max_tokens` — optional, 1-4096, default 1024
- `temperature` — optional, 0.0-2.0, default 0.7

**Response (200):**
```json
{
  "model": "string",
  "choices": [
    {
      "message": { "role": "assistant", "content": "string" },
      "finish_reason": "stop | length"
    }
  ],
  "usage": {
    "prompt_tokens": 0,
    "completion_tokens": 0,
    "total_tokens": 0
  }
}
```

### Get AI Usage
`GET /ai/usage` — Roles: admin, user, readonly

**Response (200):**
```json
{
  "request_count": 0,
  "total_prompt_tokens": 0,
  "total_completion_tokens": 0,
  "total_tokens": 0
}
```

---

## Salesforce (API Proxy & Tools)

Interact with Salesforce orgs through the platform without handling OAuth tokens, credential storage, or token refresh. The platform decrypts your connection's credentials, injects the Bearer token, forwards the request to the correct Salesforce instance, and returns the response. Tokens are refreshed automatically every 30 minutes, and any remaining 401 `INVALID_SESSION_ID` from Salesforce triggers a transparent refresh + retry — your product never needs to worry about expiration.

**Prerequisites:** Before using these endpoints, the tenant must have an active Salesforce connection (`type_id: "salesforce"`, `status: "active"`) created via the [Connections](#connections) API with a completed OAuth flow. The connection's `metadata.environment` determines whether the OAuth flow targets production (`login.salesforce.com`) or sandbox (`test.salesforce.com`). See the [Salesforce section under Create Connection](#salesforce-type_id-salesforce) for the exact metadata and credentials shape.

**Multi-org support:** a tenant can have any number of Salesforce connections simultaneously — one per org. Products reference a specific org by passing its `connection_id` in the URL. Mixing production and sandbox orgs under the same tenant is supported.

**Two auth modes:** all the endpoints below require a tenant JWT (`Authorization: Bearer ...`) and are scoped to that single tenant. For background workers that have no user session, each endpoint has a product-scoped counterpart under `/platform/salesforce/*` authenticated by `X-Product-Key` — see [Product-Scoped Salesforce](#product-scoped-salesforce-for-background-workers) at the end of this section.

**Typical use cases:**
- Query Salesforce data (Accounts, Contacts, Opportunities) for display in your product
- Build Salesforce-to-Salesforce migration tools with metadata introspection
- Sync CRM data on a schedule using the Scheduler API + Salesforce query endpoint
- Create, update, or delete Salesforce records from your product's UI
- Inspect org metadata (object definitions, field types, picklist values) for dynamic UIs

### Proxy Request
`POST /salesforce/{connection_id}/request` — Roles: admin, user

Forward any HTTP request to the Salesforce REST API. Use this for operations not covered by the specialized endpoints below, or when you need full control over the request.

**Path Parameters:**
- `connection_id` — UUID of an active Salesforce connection belonging to this tenant

**Request:**
```json
{
  "method": "GET | POST | PUT | PATCH | DELETE (required)",
  "path": "string (required, e.g. '/services/data/v62.0/sobjects/Account/001xx000003GYnBAAW')",
  "body": { "any": "JSON object (optional, for POST/PUT/PATCH)" },
  "headers": { "extra": "headers (optional, merged with auth headers)" }
}
```

**Response (200):**
The raw Salesforce API response, passed through as-is.

### SOQL Query
`POST /salesforce/{connection_id}/query` — Roles: admin, user

Execute a SOQL query with automatic pagination. The platform follows Salesforce's `nextRecordsUrl` links and accumulates all records into a single response (up to 10 pagination rounds).

**Request:**
```json
{
  "soql": "string (required, e.g. 'SELECT Id, Name FROM Account WHERE Industry = \\'Technology\\'')",
  "include_deleted": false
}
```

- `soql` — required, 1-20000 chars
- `include_deleted` — optional, default false. When true, uses `queryAll` to include deleted/archived records.

**Response (200):**
```json
{
  "totalSize": 42,
  "done": true,
  "records": [
    {
      "attributes": { "type": "Account", "url": "/services/data/v62.0/sobjects/Account/001xx..." },
      "Id": "001xx000003GYnBAAW",
      "Name": "Acme Corp"
    }
  ]
}
```

### List Objects
`GET /salesforce/{connection_id}/objects` — Roles: admin, user, readonly

List all SObjects available in the connected Salesforce org.

**Response (200):**
```json
{
  "sobjects": [
    {
      "name": "Account",
      "label": "Account",
      "queryable": true,
      "createable": true,
      "updateable": true,
      "deletable": true,
      "custom": false
    }
  ]
}
```

### Describe Object
`GET /salesforce/{connection_id}/describe/{sobject}` — Roles: admin, user, readonly

Get full metadata for a Salesforce SObject, including field definitions, relationships, picklist values, and record type information. Essential for building dynamic UIs or data mapping tools.

**Path Parameters:**
- `connection_id` — UUID of an active Salesforce connection
- `sobject` — API name of the SObject (e.g. `Account`, `Contact`, `Custom_Object__c`)

**Response (200):**
The full Salesforce describe response, including:
```json
{
  "name": "Account",
  "label": "Account",
  "fields": [
    {
      "name": "Name",
      "label": "Account Name",
      "type": "string",
      "length": 255,
      "nillable": false,
      "createable": true,
      "updateable": true,
      "picklistValues": []
    }
  ],
  "recordTypeInfos": [],
  "childRelationships": []
}
```

### Composite Request
`POST /salesforce/{connection_id}/composite` — Roles: admin, user

Batch up to 25 Salesforce API calls into a single request using the Composite API. Reduces round trips and simplifies operations that need multiple related API calls.

**Request:**
```json
{
  "requests": [
    {
      "method": "GET",
      "url": "/services/data/v62.0/sobjects/Account/describe",
      "reference_id": "accountDescribe"
    },
    {
      "method": "GET",
      "url": "/services/data/v62.0/sobjects/Contact/describe",
      "reference_id": "contactDescribe"
    }
  ]
}
```

- `requests` — required, 1-25 subrequests
- Each subrequest requires `method`, `url`, and `reference_id`
- `body` is optional per subrequest (for POST/PUT/PATCH)

**Response (200):**
```json
{
  "compositeResponse": [
    {
      "body": { "...describe response..." },
      "httpHeaders": {},
      "httpStatusCode": 200,
      "referenceId": "accountDescribe"
    },
    {
      "body": { "...describe response..." },
      "httpHeaders": {},
      "httpStatusCode": 200,
      "referenceId": "contactDescribe"
    }
  ]
}
```

### Token Refresh

OAuth access tokens are refreshed automatically by the platform every 30 minutes for all OAuth providers (Salesforce, Smartsheet, Dynamics 365, Slack). In addition, the Salesforce proxy endpoints perform **reactive refresh-on-401** — if Salesforce returns 401 `INVALID_SESSION_ID`, the platform transparently refreshes the token and retries the request once. Other providers rely on the scheduled refresh only.

Products can also force an immediate refresh via `POST /connections/{connection_id}/refresh` — useful if you suspect the token is stale or you want to verify credentials are still valid without re-running the OAuth flow. See [Refresh OAuth Token](#refresh-oauth-token) in the Connections section.

If a connection ends up in status `error` with the message "Token refresh failed" (typically because the stored `refresh_token` has been revoked or expired), the tenant admin must re-authorize by running `POST /connections/{connection_id}/oauth/initiate` again.

### Product-Scoped Salesforce (for background workers)

For a product's background worker that has no user session, the same endpoints above are available in a cross-tenant variant authenticated by `X-Product-Key` instead of a tenant JWT. Every request cross-checks that `tenant_id` is assigned to the product behind the API key — cross-product access returns `403 AUTHORIZATION_ERROR`. All the usual per-connection semantics apply (status must be `active`, refresh-on-401 runs transparently, usage is still tracked per-tenant).

Auth: pass `X-Product-Key: pk_live_...` and include `tenant_id` + `connection_id` in the request (body for POSTs, query string for GETs).

#### Product-scoped SOQL query
`POST /platform/salesforce/query` — Auth: `X-Product-Key`

**Request:**
```json
{
  "tenant_id": "uuid (required)",
  "connection_id": "uuid (required)",
  "soql": "SELECT Id, Name FROM Account LIMIT 10",
  "include_deleted": false
}
```
**Response (200):** same shape as the tenant-JWT [SOQL Query](#soql-query) endpoint — `{ totalSize, records, done }` with auto-pagination up to 10 rounds.

#### Product-scoped list SObjects
`GET /platform/salesforce/objects?tenant_id=<uuid>&connection_id=<uuid>` — Auth: `X-Product-Key`

**Response (200):** same shape as the tenant-JWT [List Objects](#list-objects) endpoint — `{ sobjects: [...], count }`.

#### Product-scoped describe SObject
`GET /platform/salesforce/describe/{sobject}?tenant_id=<uuid>&connection_id=<uuid>` — Auth: `X-Product-Key`

**Response (200):** the raw Salesforce describe response for the SObject.

#### Product-scoped generic proxy
`POST /platform/salesforce/request` — Auth: `X-Product-Key`

**Request:**
```json
{
  "tenant_id": "uuid (required)",
  "connection_id": "uuid (required)",
  "method": "GET | POST | PUT | PATCH | DELETE",
  "path": "/services/data/v62.0/...",
  "headers": { "optional": "headers" },
  "body": { "optional": "json body" }
}
```
**Response (200):** raw Salesforce REST response.

> **When to use tenant-JWT vs. product-key variants:** use `/salesforce/{connection_id}/*` (tenant JWT) from your user-facing product UI — the request is already in a user session. Use `/platform/salesforce/*` (X-Product-Key) from a scheduled worker, Slack command handler, or any background process that has no user session and is processing work on behalf of any of your tenants.

---

## Egnyte (API Proxy & Tools)

Interact with Egnyte's file system through the platform without handling OAuth tokens. The platform decrypts your connection's credentials, injects the Bearer token, forwards the request to `https://{subdomain}.egnyte.com/pubapi/v1/*`, and returns the response. Tokens are auto-refreshed on 401 and on the 30-minute background schedule.

**File size limit**: ~7MB effective per file. Binary content moves through the API as base64-encoded strings in JSON bodies (API Gateway is not configured with binary media types). Larger files require Egnyte's chunked-upload flow — not in this slice.

### List Folder
`GET /egnyte/{connection_id}/folder?path=/Shared/Some/Path` — Roles: admin, user, readonly

Returns folder contents at the given Egnyte path. The path is everything after the customer's Egnyte host (e.g. `/Shared`, `/Shared/Reports`).

**Response (200):** the raw Egnyte folder-listing JSON. Includes:
- `name`, `path`, `folder_id`
- `folders`: array of subfolder objects with `name`, `path`, `folder_id`
- `files`: array of file objects with `name`, `path`, `entry_id`, `size`, `checksum`, `last_modified`, `uploaded_by`

The full response shape is whatever Egnyte returns — refer to [Egnyte's File System Management docs](https://developers.egnyte.com/docs/read/File_System_Management_API_Documentation) for the authoritative schema.

### Download File
`GET /egnyte/{connection_id}/file?path=/Shared/Reports/q3.pdf` — Roles: admin, user, readonly

Downloads a file from Egnyte. The file body is returned as a base64 string inside JSON (so the response stays within API Gateway's text-mode limits).

**Response (200):**
```json
{
  "path": "/Shared/Reports/q3.pdf",
  "content_base64": "JVBERi0xLjQK...",
  "content_type": "application/pdf",
  "size_bytes": 234567
}
```

### Upload File
`POST /egnyte/{connection_id}/file` — Roles: admin, user

Uploads (or replaces) a file at the given path. Send the file content as base64 in the request body.

**Request:**
```json
{
  "path": "/Shared/Reports/q3.pdf (required, absolute Egnyte path including filename)",
  "content_base64": "JVBERi0xLjQK... (required, base64-encoded file bytes; ~7MB max effective)",
  "content_type": "application/pdf (optional; defaults to application/octet-stream)"
}
```

**Response (201):** the raw Egnyte upload response (includes `entry_id`, `checksum`, etc.) plus `path` and `size_bytes` for convenience.

### Generic Proxy
`POST /egnyte/{connection_id}/request` — Roles: admin, user

Pass-through for any Egnyte API endpoint we don't wrap explicitly (e.g., user lookups, link creation, audit reports). The platform handles auth + refresh-on-401; you supply method, path, body, and headers.

**Request:**
```json
{
  "method": "GET | POST | PUT | PATCH | DELETE",
  "path": "/pubapi/v1/userinfo",
  "body": { "optional": "json body" },
  "headers": { "optional": "extra headers" }
}
```

**Response:** the raw Egnyte response, status code preserved. If Egnyte's response isn't JSON, returns `{"raw_body_base64": "..."}` instead.

---

## Smartsheet (API Proxy & Tools)

Interact with Smartsheet through the platform without handling OAuth tokens or credential storage. The platform decrypts your connection's credentials, injects the Bearer token, forwards the request to the Smartsheet API, and returns the response. Tokens are automatically refreshed every 30 minutes.

**Prerequisites:** The tenant must have an active Smartsheet connection (type `smartsheet`, status `active`) created via the [Connections](#connections) API — either through a completed OAuth flow or with a personal API access token (`auth_method: "api_key"`).

**Typical use cases:**
- Read Smartsheet data into your product for display, reporting, or processing
- Write CRM or app data back to Smartsheets for client-facing dashboards
- Build data pipelines that sync between Smartsheet and other systems (Salesforce, Dynamics 365)
- Discover and map Smartsheet columns for dynamic data import/export UIs
- Search across a connected Smartsheet account to find specific sheets or data

### Proxy Request
`POST /smartsheet/{connection_id}/request` — Roles: admin, user

Forward any HTTP request to the Smartsheet API (`https://api.smartsheet.com/2.0`). Use this for operations not covered by the specialized endpoints below.

**Path Parameters:**
- `connection_id` — UUID of an active Smartsheet connection belonging to this tenant

**Request:**
```json
{
  "method": "GET | POST | PUT | PATCH | DELETE (required)",
  "path": "string (required, e.g. '/users/me')",
  "body": { "any": "JSON object (optional)" },
  "headers": { "extra": "headers (optional)" }
}
```

**Response (200):**
The raw Smartsheet API response, passed through as-is.

### List Sheets
`GET /smartsheet/{connection_id}/sheets` — Roles: admin, user, readonly

List all sheets accessible to the connected Smartsheet account.

**Query Parameters:**
- `page` — int (optional, default 1)
- `pageSize` — int (optional, default 100, max 500)

**Response (200):**
```json
{
  "pageNumber": 1,
  "pageSize": 100,
  "totalPages": 1,
  "totalCount": 2,
  "data": [
    {
      "id": 123456789,
      "name": "Project Tracker",
      "accessLevel": "OWNER",
      "createdAt": "2026-01-15T10:30:00Z",
      "modifiedAt": "2026-04-10T14:22:00Z"
    }
  ]
}
```

### Get Sheet
`GET /smartsheet/{connection_id}/sheets/{sheet_id}` — Roles: admin, user, readonly

Fetch a full sheet including column definitions and row data.

**Path Parameters:**
- `sheet_id` — Smartsheet sheet ID (numeric)

**Query Parameters:**
- `page` — int (optional, for row pagination)
- `pageSize` — int (optional, default 100)
- `columnIds` — comma-separated column IDs (optional, filter to specific columns)
- `rowIds` — comma-separated row IDs (optional, filter to specific rows)

**Response (200):**
```json
{
  "id": 123456789,
  "name": "Project Tracker",
  "columns": [
    {
      "id": 1001,
      "title": "Task Name",
      "type": "TEXT_NUMBER",
      "index": 0,
      "primary": true
    },
    {
      "id": 1002,
      "title": "Status",
      "type": "PICKLIST",
      "options": ["Not Started", "In Progress", "Complete"]
    }
  ],
  "rows": [
    {
      "id": 5001,
      "rowNumber": 1,
      "cells": [
        { "columnId": 1001, "value": "Build API" },
        { "columnId": 1002, "value": "In Progress" }
      ]
    }
  ],
  "totalRowCount": 42
}
```

### Get Sheet Columns
`GET /smartsheet/{connection_id}/sheets/{sheet_id}/columns` — Roles: admin, user, readonly

Get column definitions for a sheet. Use this for building data mapping UIs where users select which Smartsheet columns to import/export.

**Response (200):**
```json
{
  "pageNumber": 1,
  "totalCount": 5,
  "data": [
    {
      "id": 1001,
      "title": "Task Name",
      "type": "TEXT_NUMBER",
      "index": 0,
      "primary": true
    },
    {
      "id": 1002,
      "title": "Due Date",
      "type": "DATE"
    },
    {
      "id": 1003,
      "title": "Status",
      "type": "PICKLIST",
      "options": ["Not Started", "In Progress", "Complete"]
    }
  ]
}
```

### Add Rows
`POST /smartsheet/{connection_id}/sheets/{sheet_id}/rows` — Roles: admin, user

Add up to 500 rows to a sheet in a single request.

**Request:**
```json
{
  "rows": [
    {
      "cells": [
        { "columnId": 1001, "value": "New Task" },
        { "columnId": 1002, "value": "2026-06-01" },
        { "columnId": 1003, "value": "Not Started" }
      ]
    }
  ],
  "to_top": false,
  "to_bottom": true
}
```

- `rows` — required, 1-500 row objects. Each row contains a `cells` array with `columnId` + `value` pairs.
- `to_top` — optional, default false. Insert at the top of the sheet.
- `to_bottom` — optional, default true. Insert at the bottom of the sheet.

**Response (200):**
```json
{
  "message": "SUCCESS",
  "resultCode": 0,
  "result": [
    {
      "id": 5002,
      "rowNumber": 43,
      "cells": [
        { "columnId": 1001, "value": "New Task" }
      ]
    }
  ]
}
```

### Update Rows
`PUT /smartsheet/{connection_id}/sheets/{sheet_id}/rows` — Roles: admin, user

Update up to 500 existing rows. Each row must include its `id`.

**Request:**
```json
{
  "rows": [
    {
      "id": 5001,
      "cells": [
        { "columnId": 1003, "value": "Complete" }
      ]
    }
  ]
}
```

**Response (200):**
Same structure as Add Rows response.

### Delete Rows
`DELETE /smartsheet/{connection_id}/sheets/{sheet_id}/rows` — Roles: admin, user

Delete up to 500 rows by their IDs.

**Request:**
```json
{
  "row_ids": [5001, 5002, 5003]
}
```

**Response (200):**
```json
{
  "message": "SUCCESS",
  "resultCode": 0
}
```

### Search
`POST /smartsheet/{connection_id}/search` — Roles: admin, user, readonly

Search across all sheets in the connected Smartsheet account by keyword.

**Request:**
```json
{
  "query": "string (required, 1-1000 chars)"
}
```

**Response (200):**
```json
{
  "results": [
    {
      "objectType": "sheet",
      "objectId": 123456789,
      "text": "Project Tracker",
      "contextData": ["Task Name: Build API"]
    },
    {
      "objectType": "row",
      "objectId": 5001,
      "parentObjectId": 123456789,
      "text": "Build API",
      "contextData": ["Status: In Progress"]
    }
  ],
  "totalCount": 2
}
```

---

## Slack (API Proxy & Tools)

Interact with Slack workspaces through the platform without handling OAuth tokens or credential storage. The platform decrypts the connection's bot token and injects it into each request. Slack bot tokens do not expire automatically, but can be revoked — if that happens, the connection status will show `error` and the tenant admin needs to re-authorize.

**Prerequisites:** The tenant must have an active Slack connection (type `slack`, status `active`) created via the [Connections](#connections) API with a completed OAuth flow. The Slack OAuth flow installs a Slack app into a workspace and grants bot scopes (`chat:write`, `channels:read`, `users:read`, `users:read.email`, `groups:read`, `files:write`, `reactions:write`).

**Typical use cases:**
- Post notifications from your product to Slack channels (deploy events, alerts, reports)
- Send DMs to users based on events in your product (mentions, assignments)
- Update previously-posted messages with new state (e.g., "deploy in progress" → "deploy complete")
- Look up Slack users by email for mapping product users to Slack users
- Upload files (reports, exports, screenshots) into channel conversations

### Proxy Request
`POST /slack/{connection_id}/request` — Roles: admin, user

Call any Slack Web API method. Use this for operations not covered by the specialized endpoints below.

**Path Parameters:**
- `connection_id` — UUID of an active Slack connection

**Request:**
```json
{
  "method": "string (required, Slack Web API method name, e.g. 'chat.postMessage')",
  "params": { "any": "params the Slack method accepts" }
}
```

**Response (200):**
The raw Slack API response. Slack returns `{"ok": true, ...}` on success. If Slack returns `ok: false`, the platform surfaces this as a 400 error with the Slack error code in the message.

### Post Message
`POST /slack/{connection_id}/messages` — Roles: admin, user

Post a message to a channel or thread. Supports plain text, rich blocks, and legacy attachments.

**Request:**
```json
{
  "channel": "string (required, channel ID like 'C12345' or name like '#general')",
  "text": "string (optional, up to 40000 chars — required if no blocks/attachments)",
  "blocks": [ "Slack Block Kit elements (optional)" ],
  "attachments": [ "legacy attachment objects (optional)" ],
  "thread_ts": "string (optional, timestamp of parent message to reply in thread)",
  "reply_broadcast": false
}
```

**Response (200):**
```json
{
  "ok": true,
  "channel": "C12345",
  "ts": "1712851200.001200",
  "message": {
    "type": "message",
    "user": "U12345",
    "text": "Hello from your product",
    "ts": "1712851200.001200"
  }
}
```

> Keep the `ts` field if you might want to update or delete the message later.

### Update Message
`PUT /slack/{connection_id}/messages` — Roles: admin, user

Update an existing message by timestamp. Useful for progress notifications that change state.

**Request:**
```json
{
  "channel": "string (required)",
  "ts": "string (required, timestamp of message to update)",
  "text": "string (optional)",
  "blocks": [ "optional" ],
  "attachments": [ "optional" ]
}
```

**Response (200):** Slack's response with the updated message.

### Delete Message
`DELETE /slack/{connection_id}/messages` — Roles: admin, user

Delete a message by timestamp.

**Request:**
```json
{
  "channel": "string (required)",
  "ts": "string (required)"
}
```

**Response (200):**
```json
{
  "ok": true,
  "channel": "C12345",
  "ts": "1712851200.001200"
}
```

### Add Reaction
`POST /slack/{connection_id}/reactions` — Roles: admin, user

Add an emoji reaction to a message. Useful for acknowledgment flows ("thumbsup" when a task is approved, "white_check_mark" when complete).

**Request:**
```json
{
  "channel": "string (required)",
  "ts": "string (required, message timestamp)",
  "name": "string (required, emoji name without colons, e.g. 'thumbsup')"
}
```

**Response (200):** `{"ok": true}`

### List Channels
`GET /slack/{connection_id}/channels` — Roles: admin, user, readonly

List channels the bot has access to. Essential for channel picker UIs.

**Query Parameters:**
- `limit` — int (optional, default 100, max 1000)
- `types` — comma-separated types (optional, default `public_channel,private_channel`). Valid values: `public_channel`, `private_channel`, `mpim`, `im`.
- `exclude_archived` — boolean (optional, default `true`)
- `cursor` — string (optional, for pagination — use `next_cursor` from previous response)

**Response (200):**
```json
{
  "ok": true,
  "channels": [
    {
      "id": "C12345",
      "name": "general",
      "is_channel": true,
      "is_private": false,
      "is_archived": false,
      "num_members": 42
    }
  ],
  "response_metadata": { "next_cursor": "" }
}
```

### List Users
`GET /slack/{connection_id}/users` — Roles: admin, user, readonly

List all users in the workspace.

**Query Parameters:**
- `limit` — int (optional, default 100)
- `cursor` — string (optional, for pagination)

**Response (200):**
```json
{
  "ok": true,
  "members": [
    {
      "id": "U12345",
      "name": "alice",
      "real_name": "Alice Smith",
      "profile": {
        "email": "alice@example.com",
        "display_name": "alice"
      },
      "is_bot": false,
      "deleted": false
    }
  ],
  "response_metadata": { "next_cursor": "" }
}
```

### Get User by Email
`GET /slack/{connection_id}/users/by-email?email={email}` — Roles: admin, user, readonly

Look up a Slack user by email. Essential for mapping product users to Slack users when you need to DM someone.

**Query Parameters:**
- `email` — string (required)

**Response (200):**
```json
{
  "ok": true,
  "user": {
    "id": "U12345",
    "name": "alice",
    "profile": { "email": "alice@example.com" }
  }
}
```

Returns Slack error `users_not_found` (as a 400 from the platform) if no user matches.

### Upload File
`POST /slack/{connection_id}/files` — Roles: admin, user

Upload a file to one or more Slack channels. The file content must be base64-encoded in the request body.

**Request:**
```json
{
  "channels": ["C12345"],
  "filename": "report.pdf",
  "content": "base64-encoded file bytes (required, max ~1MB)",
  "title": "Q1 Report (optional)",
  "initial_comment": "Here is the Q1 report (optional)"
}
```

- `channels` — required, 1-20 channel IDs
- `filename` — required, used as the display name
- `content` — required, base64-encoded file bytes

**Response (200):**
```json
{
  "ok": true,
  "file": {
    "id": "F12345",
    "name": "report.pdf",
    "title": "Q1 Report",
    "filetype": "pdf",
    "size": 524288,
    "permalink": "https://example.slack.com/files/..."
  }
}
```

### Error Handling

Slack's Web API always returns `{"ok": true/false, ...}`. When `ok` is false, the platform converts this to an HTTP 400 response with the Slack error code in the message, e.g.:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Slack error: channel_not_found"
  }
}
```

Common Slack error codes to handle in your product:
- `channel_not_found` — channel ID invalid or bot not in channel
- `not_in_channel` — bot needs to be invited to the channel first
- `users_not_found` — no user matches the given email
- `token_revoked` — the connection needs to be re-authorized
- `missing_scope` — the Slack app doesn't have the required OAuth scope

---

### Inbound Events (Product ← Slack)

The platform accepts incoming Slack events (from Event Subscriptions, slash commands, and interactive components), verifies them, and stores them per-tenant for products to poll via `GET /slack/{connection_id}/events`.

**How it works:**
1. The platform admin configures a single Slack app (per environment) and stores its `signing_secret` alongside the OAuth credentials in a platform Slack connection.
2. When a tenant completes the Slack OAuth flow, the platform records a mapping from their Slack `team_id` to their connection.
3. Slack sends events to the platform's public endpoints. The platform verifies the signature, looks up the owning tenant by `team_id`, and stores the event under that tenant.
4. Products poll one of two endpoints to retrieve events and `DELETE` them once processed:
   - Foreground / user-initiated: `GET /slack/{connection_id}/events` — authenticated by a **tenant JWT**, scoped to a single connection. Fine for UI-driven polling.
   - Background workers: `GET /platform/slack/events` — authenticated by the **product API key**, returns events across every tenant of the product in one call. See [Product-Scoped Polling](#product-scoped-polling-for-background-workers).
5. Events auto-expire after 30 days via DynamoDB TTL.

**Slack App Configuration:**
Configure your Slack app's endpoints to point at the platform:
- **Event Subscriptions → Request URL:** `https://blufyreplatform.com/slack/events`
- **Slash Commands → Request URL:** `https://blufyreplatform.com/slack/commands`
- **Interactive Components → Request URL:** `https://blufyreplatform.com/slack/interactive`

All three endpoints are public (no JWT required) — security is enforced by Slack signature verification against the platform Slack connection's `signing_secret`. Requests older than 5 minutes or with invalid signatures return `401`.

### Slack Events Receiver (Public)
`POST /slack/events` — No auth (Slack signature required)

Receives events from Slack's Event Subscriptions API. Handles the `url_verification` challenge during initial app setup, and stores `event_callback` payloads for product polling.

This endpoint is called by Slack, not by your product. It's documented here for visibility.

### Slash Commands Receiver (Public)
`POST /slack/commands` — No auth (Slack signature required)

Receives slash command invocations from Slack. Returns an immediate ephemeral acknowledgment so Slack's 3-second timeout is met, and stores the command for the product to process asynchronously.

### Interactive Components Receiver (Public)
`POST /slack/interactive` — No auth (Slack signature required)

Receives interactive component payloads (button clicks, modal submissions, select menu actions) from Slack and stores them for product polling.

### List Events
`GET /slack/{connection_id}/events` — Roles: admin, user, readonly

Retrieve stored Slack events for a connection. Products poll this endpoint to get new events (messages, mentions, slash commands, button clicks, etc.) that Slack has sent to the platform.

**Query Parameters:**
- `limit` — int (optional, default 50, max 500)
- `cursor` — string (optional, for pagination — use `next_cursor` from a previous response)

**Response (200):**
```json
{
  "events": [
    {
      "event_id": "uuid",
      "event_type": "event.message",
      "team_id": "T12345",
      "payload": {
        "type": "event_callback",
        "team_id": "T12345",
        "event": {
          "type": "message",
          "user": "U12345",
          "text": "Hello, bot!",
          "channel": "C12345",
          "ts": "1712851200.001200"
        }
      },
      "received_at": "2026-04-12T10:30:00+00:00"
    }
  ],
  "count": 1,
  "next_cursor": null
}
```

**Event types:**
- `event.{slack_event_type}` — from Event Subscriptions (e.g., `event.message`, `event.app_mention`, `event.reaction_added`)
- `command.{slash_command}` — slash command invocations (e.g., `command.deploy` for `/deploy`)
- `interactive.{type}` — interactive component interactions (e.g., `interactive.block_actions`, `interactive.view_submission`)

> **Polling strategy:** Call this endpoint periodically (every few seconds for real-time use cases, or on-demand when your product needs to check). After processing an event, delete it so it's not returned again. For a background worker that has no user session, use the product-scoped variant at [`GET /platform/slack/events`](#list-events-across-all-tenants) instead — it returns events across every tenant of your product with a single `X-Product-Key` auth and avoids having to store tenant JWTs server-side.

### Delete Event
`DELETE /slack/{connection_id}/events/{event_id}` — Roles: admin, user

Delete a processed event so it's not returned by future `GET /events` calls. Use this after your product has successfully handled an event.

**Response (200):**
```json
{ "deleted": true }
```

> **Note:** Events also auto-expire 30 days after receipt via DynamoDB TTL. Deletion is optional but recommended to keep your event queue small.

### Product-Scoped Polling (for background workers)

Tenant-JWT authentication is not practical for a product's background worker that has no user session — access tokens live 15 minutes and the worker would otherwise need to persist refresh tokens per tenant. The endpoints below are authenticated by the product's `X-Product-Key` and return/write data partitioned by `tenant_id + connection_id`, so a single background process can service every tenant that uses the product.

Auth: pass `X-Product-Key: pk_live_...`. Every request is checked against the product's tenant assignments — tenants that aren't assigned to the calling product return `403 AUTHORIZATION_ERROR`.

#### List events across all tenants
`GET /platform/slack/events` — Auth: `X-Product-Key`

Returns Slack events from every tenant assigned to the calling product, newest first.

**Query parameters:**
- `limit` — int (optional, default 500, max 5000)
- `since` — ISO 8601 (optional) — only return events with `received_at >= since`
- `cursor` — ISO 8601 (optional) — only return events with `received_at < cursor`. Use the `next_cursor` from the previous response to paginate backward through older events.

**Response (200):**
```json
{
  "events": [
    {
      "tenant_id": "uuid",
      "connection_id": "uuid",
      "event_id": "uuid",
      "event_type": "event.app_mention",
      "team_id": "T12345",
      "payload": { "...": "full Slack payload, including response_url for commands/interactive" },
      "received_at": "2026-04-17T18:30:00+00:00"
    }
  ],
  "count": 1,
  "next_cursor": null
}
```

> **Polling pattern:** The typical background worker loop is: poll with `limit=500` and no cursor, process each event (often replying via `response_url` directly, which bypasses Slack's 3-second rule), DELETE each after successful processing, then poll again on an interval. If `next_cursor` is non-null, there were more events in the window — drain those pages immediately, then resume interval polling.
>
> **Pace your loop.** This endpoint is rate-limited to **1 request/second (burst 3)** per product — sleep ~1s between polls (or back off on an empty result). A tight no-delay loop will mostly receive HTTP 429s and waste compute; it does not deliver events faster. When idle (no events), a 2–5s interval is plenty.

#### Post a reply
`POST /platform/slack/messages` — Auth: `X-Product-Key`

Posts a message to a Slack channel on behalf of a specific tenant's connection.

**Request:**
```json
{
  "tenant_id": "uuid (required) — must be a tenant assigned to this product",
  "connection_id": "uuid (required) — must be a Slack connection on that tenant",
  "channel": "string (required) — channel ID or name",
  "text": "string (optional)",
  "blocks": [{ "...": "Block Kit blocks" }],
  "attachments": [{ "...": "legacy attachments" }],
  "thread_ts": "string (optional) — reply in a thread",
  "reply_broadcast": false
}
```

**Response (200):** Raw Slack `chat.postMessage` response (`{"ok": true, "ts": "...", "channel": "..."}` on success).

#### Delete a processed event
`DELETE /platform/slack/events/{event_id}` — Auth: `X-Product-Key`

Deletes a specific stored event. The body must include `tenant_id` and `connection_id` so the platform can locate the event and verify it belongs to a tenant the product owns.

**Request body:**
```json
{
  "tenant_id": "uuid (required)",
  "connection_id": "uuid (required)"
}
```

**Response (200):** `{ "deleted": true }`

---

## Storage (File Upload/Download)

Secure file storage backed by S3 with presigned URLs. Files are scoped to the tenant and storage quotas are enforced. The two-step upload flow (request presigned URL, then confirm) ensures files are properly tracked.

**Typical use cases:**
- User-uploaded documents, images, or attachments
- Report or export file generation and download
- Import files (CSVs, spreadsheets) for data processing
- Any binary content your product needs to store per-tenant

### Request Upload
`POST /files/upload` — Roles: admin, user

**Request:**
```json
{
  "file_name": "string (1-255 chars, required)",
  "content_type": "string (1-100 chars, required, e.g. 'application/pdf')",
  "size_bytes": 0
}
```

- `size_bytes` — required, 1 to 104857600 (100MB max)

**Response (201):**
```json
{
  "file_id": "string",
  "upload_url": "string (presigned S3 PUT URL)",
  "s3_key": "string",
  "expires_in": 3600
}
```

Upload the file with a PUT request to `upload_url`, then confirm.

### Confirm Upload
`POST /files/{file_id}/confirm` — Roles: admin, user

Call after uploading to the presigned URL.

**Response (200):**
```json
{
  "file_id": "string",
  "file_name": "string",
  "content_type": "string",
  "size_bytes": 0,
  "status": "confirmed",
  "uploaded_by": "string",
  "created_at": "ISO 8601 | null",
  "confirmed_at": "ISO 8601 | null"
}
```

### Get Download URL
`GET /files/{file_id}/download` — Roles: admin, user, readonly

**Response (200):**
```json
{
  "file_id": "string",
  "download_url": "string (presigned S3 GET URL)",
  "file_name": "string",
  "expires_in": 3600
}
```

### List Files
`GET /files` — Roles: admin, user, readonly

**Query Parameters:**
- `limit` — int (optional, default 50, max 100)
- `last_key` — string, base64-encoded (optional, for pagination)

**Response (200):**
```json
{
  "files": [
    {
      "file_id": "string",
      "file_name": "string",
      "content_type": "string",
      "size_bytes": 0,
      "status": "string",
      "uploaded_by": "string",
      "created_at": "ISO 8601 | null",
      "confirmed_at": "ISO 8601 | null"
    }
  ],
  "count": 0,
  "storage_used_bytes": 0,
  "storage_quota_bytes": 0,
  "next_key": "string | null"
}
```

### Delete File
`DELETE /files/{file_id}` — Roles: admin

**Response (200):**
```json
{ "deleted": true }
```

---

## Messaging (Email via SES)

Send transactional emails through AWS SES on behalf of a tenant. Email history is logged for audit and debugging.

**Typical use cases:**
- Welcome emails, password resets, account notifications
- Report delivery or scheduled digest emails
- Customer-facing notifications triggered by app events
- Any transactional email your product needs to send

### Send Email
`POST /messaging/send` — Roles: admin

**Request:**
```json
{
  "from_address": "string (valid email, required)",
  "to_addresses": ["string (1-50 recipients, required)"],
  "subject": "string (1-1000 chars, required)",
  "body_html": "string (optional, max 100000 chars)",
  "body_text": "string (optional, max 100000 chars)",
  "reply_to": ["string (optional)"]
}
```

**Response (201):**
```json
{
  "message_id": "string (SES message ID)",
  "status": "sent"
}
```

### Get Email History
`GET /messaging/history` — Roles: admin, user, readonly

**Response (200):**
```json
{
  "emails": [
    {
      "from_address": "string",
      "to_addresses": ["string"],
      "subject": "string",
      "message_id": "string | null",
      "status": "string | null",
      "sent_at": "ISO 8601 | null"
    }
  ],
  "count": 0
}
```

---

## Scheduler (Scheduled Tasks)

Define recurring or one-time tasks that run on a cron schedule. Use this to build background processing, periodic syncs, or automated workflows per tenant.

**Typical use cases:**
- Nightly CRM data sync (pull latest from Salesforce every night)
- Weekly report generation and email delivery
- Periodic cleanup or archival of old data
- Scheduled AI analysis runs on new data

### List Tasks
`GET /scheduler/tasks` — Roles: admin, user, readonly

**Response (200):**
```json
{
  "tasks": [
    {
      "task_id": "string",
      "name": "string",
      "schedule": "string (cron expression)",
      "task_type": "string",
      "config": {},
      "status": "string | null",
      "last_run_at": "ISO 8601 | null",
      "last_run_status": "string | null",
      "created_at": "ISO 8601 | null"
    }
  ],
  "count": 0
}
```

### Get Task
`GET /scheduler/tasks/{task_id}` — Roles: admin, user, readonly

**Response (200):** Single task object.

### Create Task
`POST /scheduler/tasks` — Roles: admin

**Request:**
```json
{
  "name": "string (1-200 chars, required)",
  "schedule": "string (cron format, 1-100 chars, required)",
  "task_type": "string (required)",
  "config": {}
}
```

**Response (201):** Single task object.

### Update Task
`PUT /scheduler/tasks/{task_id}` — Roles: admin

**Request (all fields optional):**
```json
{
  "name": "string | null",
  "schedule": "string | null",
  "config": "object | null",
  "status": "string | null"
}
```

**Response (200):** Single task object.

### Delete Task
`DELETE /scheduler/tasks/{task_id}` — Roles: admin

**Response (200):**
```json
{ "deleted": true }
```

---

## Billing (Charges)

The platform provides billing services for SaaS products to charge their tenants through a single central Stripe account owned by the platform operator. Products decide the amount, timing, and description of each charge; the platform handles all Stripe plumbing.

### What products can do

- Create a charge against any tenant assigned to their product (`POST /billing/charge`)
- Decide the amount, currency, description, collection method, and due date for each charge
- List and read charges they created (`GET /billing/charges`, `GET /billing/charges/{id}`)
- Refund charges they created, fully or partially (`POST /billing/charges/{id}/refund`)
- Request a payment-method collection URL to hand to the tenant (`POST /billing/setup-payment-method`)
- Attach arbitrary `metadata` to charges for their own tracking (e.g., internal invoice numbers, subscription cycle IDs)

### What products cannot do

- **Cannot talk to Stripe directly.** Products do not have Stripe API keys. All Stripe interactions go through the platform. Products cannot list Stripe customers, create subscriptions, manage products/prices, pull reports, or access the Stripe Dashboard.
- **Cannot charge tenants they don't own.** The platform verifies on every `POST /billing/charge` that the tenant has an active assignment to the calling product. Attempting to charge a tenant assigned to a different product returns `401`.
- **Cannot read or modify charges created by other products.** `GET`, `PATCH`, and `refund` endpoints scope to the calling product's API key. Cross-product requests return `404` (never `403`, to avoid leaking existence).
- **Cannot manage Stripe customers directly.** The platform auto-creates a Stripe Customer for each tenant on their first charge and stores the `stripe_customer_id` on the tenant record. Products never see raw Stripe customer IDs except in read-only responses.
- **Cannot modify a charge after creation.** The only post-creation mutation is refund. Amount, currency, description, and tenant are immutable. To correct a mistake, refund the charge and create a new one.
- **Cannot change the billing email.** Stripe sends invoice emails to the tenant's admin user email (the first active user with `role: "admin"`). To change where invoices go, update that user in the platform, not in Stripe.
- **Cannot retry a failed auto-charge.** If `charge_automatically` returns `payment_failed`, create a new charge after resolving the underlying issue (usually a new payment method via `setup-payment-method`). The platform does not auto-retry.
- **Cannot receive Stripe webhooks directly.** Stripe sends all webhooks to the platform. The platform updates charge status in its own DB. Products observe status changes by polling `GET /billing/charges` or `GET /billing/charges/{id}`.
- **Cannot access payment method details.** Card numbers, CVVs, expiration dates, and other PCI-scoped data never flow through the platform and are never exposed to products. Payment methods are collected by Stripe's hosted Checkout session (via `setup-payment-method`) and stored inside Stripe's vault.
- **Cannot test for free.** There is no test mode or sandbox — all charges hit real money. Do not call `POST /billing/charge` from automated tests or CI pipelines. Use mocked unit tests in your product's code instead, or void/refund any smoke-test charges immediately.

### Separation of concerns

| Concern | Handled by |
|---------|------------|
| Stripe API key management | Platform (stored in AWS Secrets Manager) |
| Stripe webhook signature verification | Platform |
| Stripe customer creation and lookup | Platform (auto-created per tenant) |
| Stripe invoice creation, finalization, sending | Platform |
| Payment method collection (PCI scope) | Stripe-hosted Checkout (via platform-provided URL) |
| Deciding what to charge, when, and why | Product |
| Storing billing state (charges, amounts, statuses) | Platform |
| Observing charge status (paid / failed / refunded) | Product (polls the platform) |
| Business logic triggered on payment (e.g., unlock a feature) | Product (on status change) |
| Refund decisions | Product (calls platform refund endpoint) |

### Prerequisites before your first charge against a tenant

1. **The tenant must exist** and be assigned to your product. Tenants that self-register via `POST /register` with your product API key are automatically assigned; manually-created tenants must be assigned by the platform admin.
2. **The tenant must have at least one active user with a valid email** (role `admin` preferred). Stripe sends the hosted invoice email to this address. If no user has an email, `POST /billing/charge` returns `400` with a clear error.
3. **For `charge_automatically` only:** the tenant must have a payment method on file. If they don't, the platform first needs to create a Checkout setup session via `POST /billing/setup-payment-method`, and the tenant must complete it. Until then, use `send_invoice` mode.

### Authentication summary

| Endpoint | Product API key (`X-Product-Key`) | Tenant JWT (`Authorization: Bearer`) |
|----------|-----------------------------------|--------------------------------------|
| `POST /billing/charge` | ✅ Required | ❌ Rejected (401) |
| `POST /billing/charges/{id}/refund` | ✅ Required | ❌ Rejected (401) |
| `POST /billing/setup-payment-method` | ✅ Required | ❌ Rejected (401) |
| `GET /billing/charges` | ✅ (scoped to product) | ✅ (scoped to tenant) |
| `GET /billing/charges/{id}` | ✅ (own charges only) | ✅ (own tenant's charges only) |

### Typical use cases

- **Monthly subscription billing:** product calls `POST /billing/charge` on the 1st of each month with `collection_method: charge_automatically` (requires prior payment method setup).
- **One-time purchases:** product creates a charge when a user completes a transaction in the product's UI.
- **Invoice-based billing:** product bills on a custom schedule with `collection_method: send_invoice`, e.g., after manually completing professional-services work.
- **Usage-based billing:** product tracks usage internally, sums it at the end of the billing period, and creates a single charge with a description like "April 2026 usage (12,450 API calls)".

### Create a Charge
`POST /billing/charge` — Requires product API key

Creates a Stripe invoice for the tenant and records a charge in the platform DB. Stripe handles the rest (emailing the hosted page OR auto-charging the saved payment method, depending on collection_method).

**Request:**
```json
{
  "tenant_id": "string (required) — the tenant to charge",
  "amount_cents": 50000,
  "description": "string (1-500 chars, required)",
  "collection_method": "send_invoice | charge_automatically (default: send_invoice)",
  "days_until_due": 30,
  "currency": "usd",
  "metadata": { "string": "string (optional)" }
}
```

- `amount_cents` — integer, min 1, max 100000000 ($1M)
- `days_until_due` — only meaningful for `send_invoice`; 1-365 days
- `currency` — 3-letter ISO code, lowercase (default: usd)
- `metadata` — arbitrary string key/value pairs attached to the charge + Stripe invoice metadata

**Collection methods:**

| Method | What happens |
|--------|--------------|
| `send_invoice` | Stripe generates a hosted invoice page and emails it to the tenant. The tenant pays via the link on their own schedule. `days_until_due` sets the invoice due date. |
| `charge_automatically` | Stripe immediately charges the tenant's default payment method. Fails with a clear error if no payment method is on file — use `POST /billing/setup-payment-method` first to collect one. |

**Response (201):**
```json
{
  "charge_id": "uuid",
  "tenant_id": "string",
  "product_id": "string",
  "product_name": "string",
  "amount_cents": 50000,
  "currency": "usd",
  "description": "string",
  "collection_method": "send_invoice",
  "status": "open",
  "stripe_invoice_id": "in_1234...",
  "hosted_invoice_url": "https://invoice.stripe.com/i/...",
  "metadata": {},
  "refund_amount_cents": 0,
  "failure_message": "",
  "days_until_due": 30,
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601",
  "paid_at": "",
  "refunded_at": ""
}
```

Keep the `charge_id` if you need to refund or look it up later. The `hosted_invoice_url` is the tenant-facing payment page URL.

### List Charges
`GET /billing/charges` — Accepts product API key OR tenant JWT

- Product API key: returns charges this product created (optionally filtered by tenant_id)
- Tenant JWT: returns charges on this tenant's account across all products

**Query parameters:**
- `tenant_id` — filter by tenant (product API key only; ignored with tenant JWT since it's already scoped)
- `limit` — int, default 50, max 500
- `cursor` — opaque base64 pagination cursor from a previous response

**Response (200):**
```json
{
  "charges": [ "...charge objects..." ],
  "count": 0,
  "next_cursor": "string | null"
}
```

### Get a Charge
`GET /billing/charges/{charge_id}` — Accepts product API key OR tenant JWT

Returns a single charge. Ownership checked: product API keys can only read charges they created; tenant JWTs can only read charges on their own account. Returns 404 otherwise.

### Refund a Charge
`POST /billing/charges/{charge_id}/refund` — Requires product API key

Refunds a paid charge (full or partial). Only the product that created the charge can refund it.

**Request:**
```json
{
  "amount_cents": 50000,
  "reason": "string (optional)"
}
```

- `amount_cents` — optional; if omitted, refunds the full remaining amount
- `reason` — optional free-text reason (stored as Stripe refund metadata)

**Response (200):** The updated charge object with `status` set to `refunded` (or `partially_refunded`), `refund_amount_cents` populated, and `refunded_at` timestamp set.

**Errors:**
- 400 if the charge is not in `paid` or `partially_refunded` state
- 400 if the refund amount exceeds the remaining refundable amount
- 404 if the charge doesn't belong to the calling product

### Setup Payment Method
`POST /billing/setup-payment-method` — Requires product API key

Creates a Stripe Checkout session in **setup mode** for collecting a payment method from a tenant. Use this before calling `POST /billing/charge` with `collection_method: charge_automatically`.

**Request:**
```json
{
  "tenant_id": "string (required)",
  "success_url": "https://yourproduct.com/billing/success",
  "cancel_url": "https://yourproduct.com/billing/cancel"
}
```

**Response (201):**
```json
{
  "session_id": "cs_test_...",
  "url": "https://checkout.stripe.com/c/pay/...",
  "tenant_id": "string",
  "stripe_customer_id": "cus_..."
}
```

Share the `url` with the tenant (email, in-app link, etc.). When they complete the checkout, Stripe stores the payment method on the tenant's Stripe Customer and sets it as the default. After that, `charge_automatically` will work for that tenant.

### Charge Status Values

| Status | Meaning | Product action |
|--------|---------|----------------|
| `draft` | Invoice not yet finalized in Stripe (transient, rare) | Wait — this should resolve to `open` within seconds |
| `open` | Invoice finalized, waiting for payment | Nothing required; tenant pays on their own |
| `paid` | Payment received and settled | Grant the tenant whatever the payment was for (unlock a feature, ship an order, etc.) |
| `payment_failed` | `charge_automatically` attempt failed | Check `failure_message`; prompt the tenant to update their payment method via a new `setup-payment-method` URL, then create a new charge |
| `void` | Invoice voided (in the Stripe Dashboard, manually, or because it expired past `days_until_due`) | Nothing recoverable — if payment is still needed, create a new charge |
| `partially_refunded` | Some of the payment has been refunded | Informational |
| `refunded` | Full amount refunded | Revoke whatever was granted on `paid` |

**Status transition rules:**

- `draft` → `open` → (paid \| payment_failed \| void)
- `paid` → `partially_refunded` → `refunded` (via refund endpoint)
- `paid` → `refunded` (via full refund)
- Statuses are **append-only** from the product's perspective — a `refunded` charge never goes back to `paid`. If a tenant disputes a refund, create a new charge.

**How status changes reach the product:**

Products poll — there is no push notification from the platform to the product. After creating a charge, call `GET /billing/charges/{id}` (or list with filters) to observe the current status. For subscription flows, poll shortly after creation to catch auto-charge results; for invoice flows, the `paid` status may take hours to days since it depends on the tenant manually paying.

### Stripe Webhook (platform-only)

`POST /billing/webhooks/stripe` — Public endpoint; only Stripe calls this.

**This endpoint is not for products to call.** It's the receiver for Stripe-originated webhook events that update charge status in the platform DB. Listed here only for transparency about how status transitions happen.

The platform processes these Stripe events:
- `invoice.paid` → marks the linked charge as `paid` and sets `paid_at`
- `invoice.payment_failed` → marks as `payment_failed` with `failure_message`
- `invoice.voided` → marks as `void`
- `charge.refunded` → marks as `refunded` or `partially_refunded`

Webhook signatures are verified against the platform's Stripe webhook secret. Events from an unknown or unauthorized source are rejected with `401`.

### Example: Monthly subscription flow

```
1. Tenant signs up through a product. Product calls POST /register.
2. (Optional) Product calls POST /billing/setup-payment-method to collect a card on file.
3. On the 1st of each month, product calls POST /billing/charge:
   {
     "tenant_id": "abc-123",
     "amount_cents": 9900,
     "description": "March 2026 subscription",
     "collection_method": "charge_automatically"
   }
4. Platform creates a Stripe invoice. If card is on file, Stripe auto-charges.
5. Stripe sends invoice.paid webhook (or invoice.payment_failed).
6. Platform updates charge status. Product can poll GET /billing/charges to see the result.
```

### Example: Ad-hoc invoice-based billing

```
1. Product decides to invoice a client for consulting work.
2. Product calls POST /billing/charge:
   {
     "tenant_id": "abc-123",
     "amount_cents": 500000,
     "description": "Q1 2026 custom integration work",
     "collection_method": "send_invoice",
     "days_until_due": 14
   }
3. Stripe emails the tenant admin a hosted invoice page with a "Pay" button.
4. Tenant pays when they get around to it.
5. Stripe sends invoice.paid webhook. Platform updates the charge.
```

---

## Health

Simple uptime check. Use this in monitoring dashboards, CI/CD pipelines, or load balancer health checks to verify the platform is running.

### Health Check
`GET /health` — No auth required

**Response (200):**
```json
{
  "status": "healthy",
  "version": "0.1.0",
  "environment": "dev | prod"
}
```

## Feedback & Bug Reports

Report bugs, request features, ask questions, and suggest improvements against the Blufyre platform. Feedback is scoped to a tenant and product, tracked through a status lifecycle, and can accumulate comments as the platform team triages and resolves it.

This system is optimized for both human and AI-generated submissions. Humans can file reports from their product UI, and products built on top of the platform can submit feedback automatically when their code (or the LLM driving it) encounters unexpected behavior. A well-structured automated bug report is often faster to resolve than a human one because it already contains the request ID, the failing endpoint, and the exact error message.

**Typical use cases:**
- A tenant user reports a bug from inside your product's UI
- Your product's LLM agent hits an unexpected 4xx/5xx from the platform and files a structured bug report automatically
- A developer requests a new feature or integration
- A tenant asks a question about expected API behavior

### Authentication

Feedback endpoints accept two authentication modes, but not uniformly:

- **POST /feedback (create):** Accepts **either** a tenant JWT **or** a product API key. Logged-in tenant users submit via JWT. Products or background agents that want to file reports programmatically (including AI-generated reports from inside a running product) can authenticate with their product API key. **Product attribution:** an API-key submission is attributed to that product automatically. A tenant-JWT submission carries no product identity, so include an optional `product_id` in the body to attribute it (validated against the tenant's product assignments) — otherwise the item is recorded without a product.
- **GET / PATCH / comment endpoints:** Require a **tenant JWT only**. Product API keys cannot list, read, update, or comment on feedback. This prevents cross-product data leakage — a product API key can emit reports but cannot enumerate or read reports (including those filed by other products under the same tenant).

### Structured Context

Every feedback item has a `context` object with optional structured fields. Filling this in is the single biggest accelerator of resolution time: it lets the platform team (and its AI triage reviewer) reproduce the failure without a back-and-forth. Fill in as many fields as you can, especially `request_id`, `error_message`, `expected_behavior`, and `actual_behavior`.

**Example of a well-structured bug report:**
```json
{
  "type": "bug",
  "title": "Salesforce query returns 500 on large result set",
  "description": "Running a SOQL query that returns ~8k rows consistently 500s, while the same query with LIMIT 1000 succeeds.",
  "severity": "high",
  "context": {
    "environment": "prod",
    "api_endpoint": "POST /salesforce/{id}/query",
    "http_method": "POST",
    "http_status_code": 500,
    "request_payload": { "soql": "SELECT Id, Name FROM Account" },
    "error_message": "Internal server error",
    "request_id": "req_9f8a2c1b",
    "expected_behavior": "Paginated results or a streaming response for large queries",
    "actual_behavior": "500 after ~30s with no body",
    "steps_to_reproduce": [
      "Connect a Salesforce org with >8k Account records",
      "POST /salesforce/{id}/query with the SOQL above",
      "Observe 500"
    ],
    "connection_type": "salesforce",
    "sdk_version": "blufyre-js@0.2.1"
  },
  "tags": ["salesforce", "pagination"]
}
```

**IMPORTANT:** Redact secrets (tokens, passwords, PII) from `request_payload` and `response_payload` before submitting. The platform team will see them verbatim.

### Create Feedback
`POST /feedback` — Requires tenant JWT **or** product API key

**Request:**
```json
{
  "type": "bug | feature_request | question | improvement (required)",
  "title": "string (1-200 chars, required)",
  "description": "string (1-10000 chars, markdown supported, required)",
  "severity": "low | medium | high | critical (default: medium)",
  "product_id": "string (optional) — which product this is about. Ignored when authenticating with a product API key (that identity wins). For tenant-JWT submissions it is validated against the tenant's product assignments and rejected (400) if the tenant isn't assigned to it.",
  "context": {
    "environment": "dev | prod (optional)",
    "api_endpoint": "string (optional, e.g. 'POST /salesforce/{id}/query')",
    "http_method": "string (optional)",
    "http_status_code": 0,
    "request_payload": { "any": "JSON (optional, REDACT SECRETS)" },
    "response_payload": { "any": "JSON (optional)" },
    "error_message": "string (optional, up to 5000 chars)",
    "request_id": "string (optional, from the X-Request-Id response header or audit log)",
    "expected_behavior": "string (optional)",
    "actual_behavior": "string (optional)",
    "steps_to_reproduce": ["string", "string"],
    "connection_type": "string (optional, e.g. 'slack', 'salesforce')",
    "sdk_version": "string (optional)",
    "user_agent": "string (optional)"
  },
  "tags": ["string", "string (up to 20)"]
}
```

**Response (201):** The full feedback item (see **Feedback Item shape** below).

### List Feedback
`GET /feedback` — Roles: tenant JWT only (product API keys not accepted)

**Query parameters:**
- `status` — filter by status (optional)
- `type` — filter by type (optional)
- `limit` — int (default 50, max 500)
- `cursor` — opaque pagination cursor

**Response (200):**
```json
{
  "feedback": [ "...array of feedback items..." ],
  "count": 0,
  "next_cursor": "string | null"
}
```

### Get Feedback
`GET /feedback/{feedback_id}` — Roles: tenant JWT only

Returns a single feedback item. Cross-tenant access returns 404 (the item is not disclosed to exist).

### Update Feedback
`PATCH /feedback/{feedback_id}` — Roles: tenant JWT only

**Request (all fields optional):**
```json
{
  "title": "string",
  "description": "string",
  "severity": "string",
  "context": { "any": "context fields" },
  "tags": ["string"],
  "status": "resolved"
}
```

Tenants can only transition `status` to `resolved` (e.g. if they fixed the underlying issue on their side, or the report is no longer relevant). Transitions through other states (`triaged`, `in_progress`, `needs_info`, `wont_fix`, `duplicate`) are reserved for platform admins.

**Response (200):** The updated feedback item.

### Add Comment
`POST /feedback/{feedback_id}/comments` — Roles: tenant JWT only

**Request:**
```json
{
  "body": "string (1-5000 chars, markdown supported, required)"
}
```

**Response (200):** The updated feedback item with the new comment appended to the `comments` array.

### Feedback Item shape

```json
{
  "feedback_id": "uuid",
  "tenant_id": "string",
  "product_id": "string",
  "submitted_by": "string",
  "submitted_by_type": "user | product | ai",
  "type": "bug | feature_request | question | improvement",
  "severity": "low | medium | high | critical",
  "status": "open | triaged | in_progress | needs_info | resolved | wont_fix | duplicate",
  "title": "string",
  "description": "string",
  "context": { "...structured fields..." },
  "tags": ["string"],
  "comments": [
    {
      "comment_id": "uuid",
      "author": "string",
      "author_type": "tenant | platform_admin | ai",
      "body": "string",
      "created_at": "ISO 8601"
    }
  ],
  "ai_analysis": "string (read-only, set by platform admin)",
  "resolution": "string (read-only, set by platform admin when resolved)",
  "created_at": "ISO 8601",
  "updated_at": "ISO 8601",
  "resolved_at": "ISO 8601 | empty"
}
```

### AI-Friendly Submission

If your product is built on an LLM, you can have the model fill in the structured `context` object automatically whenever it encounters an unexpected response from the platform API. Give the LLM the failing request, the response, and the `X-Request-Id` header, and instruct it to emit a `POST /feedback` body.

The more detailed the context — particularly `request_id`, `error_message`, `expected_behavior`, and `actual_behavior` — the faster the platform team can diagnose. A good automated report will often include the exact endpoint, status code, and request ID the platform needs to pull audit logs, without any human round-trip.

### Example: AI-Generated Bug Report

Here is a realistic example of what an LLM running inside a product would emit when it hits an unexpected error from the platform:

```json
{
  "type": "bug",
  "title": "chat.postMessage returns missing_scope despite chat:write being granted",
  "description": "Our CRM notification feature is failing on message send. The platform's /slack/{id}/messages endpoint returns a 400 with 'Slack error: missing_scope', but our OAuth flow completed successfully and chat:write is in the list of approved scopes.",
  "severity": "high",
  "context": {
    "environment": "prod",
    "api_endpoint": "POST /slack/f47ac10b-58cc-4372-a567-0e02b2c3d479/messages",
    "http_method": "POST",
    "http_status_code": 400,
    "error_message": "Slack error: missing_scope",
    "request_id": "req_abc123xyz",
    "expected_behavior": "Message posted to the target channel. Our bot has chat:write and channels:read as confirmed in the Slack app config.",
    "actual_behavior": "API returns 400 with 'Slack error: missing_scope'. This only started happening after the OAuth token refresh yesterday.",
    "steps_to_reproduce": [
      "Tenant completes Slack OAuth flow with all default scopes",
      "Wait ~24 hours (so token refresh cycle runs at least once)",
      "Call POST /slack/{connection_id}/messages with a valid channel and text",
      "Observe 400 error"
    ],
    "connection_type": "slack",
    "sdk_version": "blufyre-js@0.2.1"
  },
  "tags": ["slack", "oauth", "token-refresh"]
}
```

This kind of structured report is exactly what lets the platform team (or its AI reviewer) diagnose and resolve issues quickly — the `request_id` pulls the full audit trail, the endpoint and status code scope the investigation, and `steps_to_reproduce` plus the timing clue ("after the OAuth token refresh yesterday") point directly at the likely root cause.
