# Apollo Admin API Reference

**Version:** v1  
**Base URL:** `https://admin-api.apolloscheduling.com/v1`  
**Last Updated:** 2025-12-31

## Overview

The Apollo Admin API provides RESTful endpoints for managing AppointmentCore agency administration. This API enables frontend applications to interact with agency accounts, users, and administrative functions.

### Key Features

- RESTful architecture with JSON responses
- Versioned API endpoints (currently v1)
- Comprehensive filtering and sorting capabilities
- Pagination support for large datasets
- Computed fields for aggregated data

### API Conventions

- All requests and responses use `application/json` content type
- Date/time fields are in ISO 8601 format
- All endpoints are prefixed with `/v1`
- HTTP status codes follow REST conventions (200, 201, 400, 404, etc.)

### Authentication

All API endpoints require authentication. The API supports multiple authentication methods:

**Supported Authentication Methods:**
- HTTP Basic Auth with access token as username
- HTTP Bearer Token (OAuth 2.0)
- Query parameter access token (for JSONP requests)

---

## Endpoints

### Statistics

Statistics provide aggregated information about accounts and users within the agency.

---

## GET /v1/stats

Get statistical information about accounts and users.

**URL:** `/v1/stats`  
**Method:** `GET`  
**Authentication:** Required

### Query Parameters

None

### Request Headers

| Header | Required | Description |
|--------|----------|-------------|
| `Accept` | No | Set to `application/json` (default) |

### Response

**Status Code:** `200 OK`

**Response Structure:**

```json
{
  "total_accounts": 150,
  "active_accounts": 142,
  "active_users": 1247
}
```

### Response Fields

| Field | Type | Description |
|-------|------|-------------|
| `total_accounts` | integer | Total number of accounts in the agency |
| `active_accounts` | integer | Number of active accounts (active = 1) |
| `active_users` | integer | Total number of active users across all accounts |

### Error Responses

#### 401 Unauthorized
Invalid or missing authentication credentials.

```json
{
  "message": "Invalid credentials."
}
```

#### 403 Forbidden
User does not have access to any agencies.

```json
{
  "message": "Access denied."
}
```

#### 500 Internal Server Error
Server-side error occurred.

```json
{
  "name": "Internal Server Error",
  "message": "An internal server error occurred.",
  "code": 0,
  "status": 500
}
```

### Examples

#### Basic Request

**cURL:**

```bash
curl -X GET "https://admin-api.apolloscheduling.com/v1/stats" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**JavaScript (Fetch API):**

```javascript
fetch('https://admin-api.apolloscheduling.com/v1/stats', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_ACCESS_TOKEN',
    'Accept': 'application/json'
  }
})
  .then(response => response.json())
  .then(data => {
    console.log('Total Accounts:', data.total_accounts);
    console.log('Active Accounts:', data.active_accounts);
    console.log('Active Users:', data.active_users);
  })
  .catch(error => console.error('Error:', error));
```

**TypeScript:**

```typescript
interface StatsResponse {
  total_accounts: number;
  active_accounts: number;
  active_users: number;
}

async function getStats(): Promise<StatsResponse> {
  const response = await fetch('https://admin-api.apolloscheduling.com/v1/stats', {
    method: 'GET',
    headers: {
      'Authorization': 'Bearer YOUR_ACCESS_TOKEN',
      'Accept': 'application/json'
    }
  });
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  
  return await response.json();
}

// Usage
getStats()
  .then(stats => {
    console.log(`Total: ${stats.total_accounts}, Active: ${stats.active_accounts}, Users: ${stats.active_users}`);
  })
  .catch(error => console.error('Error:', error));
```

---

## GET /v1/analytics/appointments

**URL:** `/v1/analytics/appointments`  
**Method:** `GET`  
**Authentication:** Required (typically `Authorization: Bearer …`)

Returns per-account appointment booking metrics (summary cards, chart series, and a paginated account table) scoped like [`GET /v1/account`](#get-v1account).

**Full documentation:** [Appointments analytics](./api/analytics/appointments.md)

### Query parameters

Key parameters: `start_date`, `end_date`, `group_by` (`month` \| `quarter` \| `year`), `account_status` (`active` \| `inactive` \| `all`), `search`, `page`, `per_page`, `sort`, `export=csv`. See the linked doc for defaults and semantics.

### Response

`200 OK` with `summary_cards`, `chart_series`, `accounts`, `pagination`, and `filters_echo`, or CSV when `export=csv` is set.

---

## GET /v1/analytics/user-appointments-monthly

**URL:** `/v1/analytics/user-appointments-monthly`  
**Method:** `GET`  
**Authentication:** Required

Returns pre-aggregated per-user monthly appointment create counts from `agency_organization_user_appointments_monthly` (UTC `YYYY-MM` keys; batch-hydrated, read-only).

**Full documentation:** [User appointments monthly](./api/analytics/user-appointments-monthly.md)

### Query parameters

Key parameters: `agency_id` (required for super admins), `org_id`, `year_month`, `year_month_from`, `year_month_to`, `user_in_organization_id`, `page`, `page_size`, `sort`.

### Response

`200 OK` with `data` and `meta` (`page`, `page_size`, `total`).

---

### Accounts

Accounts represent organizations within the AppointmentCore system. Each account belongs to an agency and can have multiple users, locations, and services.

---

## GET /v1/account

List all accounts for the current agency.

**URL:** `/v1/account`  
**Method:** `GET`  
**Authentication:** Required

### Query Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `id` | string | No | - | Filter by organization ID (partial match) |
| `name` | string | No | - | Filter by organization name (partial match) |
| `active` | integer | No | - | Filter by active status (0 = inactive, 1 = active) |
| `page` | integer | No | 1 | Page number for pagination |
| `per_page` | integer | No | 25 | Items per page (max: 100) |
| `sort` | string | No | `id` | Sort field. Prefix with `-` for descending order |

**Allowed sort fields:** `id`, `name`, `active`, `rec_create`, `rec_update`

### Request Headers

| Header | Required | Description |
|--------|----------|-------------|
| `Accept` | No | Set to `application/json` (default) |

### Response

**Status Code:** `200 OK`

**Response Structure:**

```json
{
  "items": [
    {
      "id": "org123",
      "name": "Acme Corporation",
      "active": 1,
      "rec_create": "2024-01-15T10:30:00Z",
      "rec_update": "2025-12-20T14:22:00Z",
      "active_users_count": 5,
      "owner_email": "owner@acmecorp.com"
    }
  ],
  "total": 150,
  "page": 1,
  "per_page": 25
}
```

### Response Fields

#### Account Items

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique organization identifier (max 50 chars) |
| `name` | string | Organization name (max 255 chars) |
| `active` | integer | Active status (0 = inactive, 1 = active) |
| `rec_create` | string | Timestamp when record was created (ISO 8601) |
| `rec_update` | string | Timestamp when record was last updated (ISO 8601) |
| `active_users_count` | integer | Number of active users in this organization (computed) |
| `owner_email` | string | Email address of the organization owner (computed) |

#### Pagination Fields

| Field | Type | Description |
|-------|------|-------------|
| `items` | array | Array of account objects |
| `total` | integer | Total number of accounts matching the filters |
| `page` | integer | Current page number (1-based) |
| `per_page` | integer | Number of items per page |

### Error Responses

#### 400 Bad Request
Invalid query parameters or malformed request.

```json
{
  "name": "Bad Request",
  "message": "Invalid sort field specified",
  "code": 0,
  "status": 400
}
```

#### 500 Internal Server Error
Server-side error occurred.

```json
{
  "name": "Internal Server Error",
  "message": "An internal server error occurred.",
  "code": 0,
  "status": 500
}
```

### Examples

#### Basic Request

**cURL:**
```bash
curl -X GET "https://admin-api.apolloscheduling.com/v1/account" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
const response = await fetch('https://admin-api.apolloscheduling.com/v1/account');
const data = await response.json();

console.log(`Total accounts: ${data.total}`);
data.items.forEach(account => {
  console.log(`${account.name} (${account.active_users_count} active users)`);
});
```

**TypeScript (Axios):**
```typescript
import axios from 'axios';

interface Account {
  id: string;
  name: string;
  active: number;
  rec_create: string;
  rec_update: string;
  active_users_count: number;
  owner_email: string;
}

interface AccountsResponse {
  items: Account[];
  total: number;
  page: number;
  per_page: number;
}

const response = await axios.get<AccountsResponse>(
  'https://admin-api.apolloscheduling.com/v1/account'
);

console.log(`Found ${response.data.total} accounts`);
```

#### Filtering Active Accounts

**cURL:**
```bash
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?active=1" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
const response = await fetch('https://admin-api.apolloscheduling.com/v1/account?active=1');
const data = await response.json();

console.log(`Active accounts: ${data.total}`);
```

#### Searching by Name

**cURL:**
```bash
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?name=acme" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
const searchTerm = 'acme';
const response = await fetch(
  `https://admin-api.apolloscheduling.com/v1/account?name=${encodeURIComponent(searchTerm)}`
);
const data = await response.json();

console.log(`Found ${data.items.length} accounts matching "${searchTerm}"`);
```

#### Pagination

**cURL:**
```bash
# Get page 2 with 50 items per page
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?page=2&per_page=50" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
async function fetchAllAccounts() {
  const allAccounts = [];
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(
      `https://admin-api.apolloscheduling.com/v1/account?page=${page}&per_page=100`
    );
    const data = await response.json();
    
    allAccounts.push(...data.items);
    
    // Check if there's a next page
    const totalPages = Math.ceil(data.total / data.per_page);
    hasMore = page < totalPages;
    page++;
  }

  return allAccounts;
}

const accounts = await fetchAllAccounts();
console.log(`Loaded ${accounts.length} total accounts`);
```

#### Sorting

**cURL:**
```bash
# Sort by name ascending
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?sort=name" \
  -H "Accept: application/json"

# Sort by name descending
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?sort=-name" \
  -H "Accept: application/json"

# Sort by creation date (newest first)
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?sort=-rec_create" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
// Get most recently updated accounts
const response = await fetch(
  'https://admin-api.apolloscheduling.com/v1/account?sort=-rec_update&per_page=10'
);
const data = await response.json();

console.log('Recently updated accounts:');
data.items.forEach(account => {
  console.log(`${account.name} - Updated: ${account.rec_update}`);
});
```

#### Combined Filters

**cURL:**
```bash
# Active accounts with name containing "tech", sorted by name, 50 per page
curl -X GET "https://admin-api.apolloscheduling.com/v1/account?active=1&name=tech&sort=name&per_page=50" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
const params = new URLSearchParams({
  active: '1',
  name: 'tech',
  sort: 'name',
  per_page: '50'
});

const response = await fetch(
  `https://admin-api.apolloscheduling.com/v1/account?${params}`
);
const data = await response.json();

console.log(`Found ${data.total} active tech companies`);
```

### Notes

- **Agency Filtering:** Currently hardcoded to agency_id = 1. Multi-agency support will be added with authentication.
- **Computed Fields:** `active_users_count` and `owner_email` are computed on-the-fly and may impact performance with large datasets.
- **Partial Matching:** Both `id` and `name` filters use SQL LIKE for partial matching (case-insensitive).
- **Max Page Size:** The `per_page` parameter is capped at 100 items to prevent performance issues.

---

## GET /v1/me

Retrieve info about the authenticated admin user and their agency/org mappings.

**URL:** `/v1/me`  
**Method:** `GET`  
**Authentication:** Required

### Response

**Status Code:** `200 OK`

```json
{
  "admin_id": 5,
  "email": "admin@example.com",
  "agencies": [
    {
      "agency_id": 1,
      "agency_name": "Agency Core",
      "organization_ids": [
        "acme-corp",
        "beta-inc"
      ]
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `admin_id` | integer | Authenticated admin user ID |
| `email` | string | Admin user email |
| `agencies` | array | Agencies linked to the admin user |

---

## GET /v1/account/{id}

Get details for a single account by ID.

**URL:** `/v1/account/{id}`  
**Method:** `GET`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The unique organization identifier |

### Request Headers

| Header | Required | Description |
|--------|----------|-------------|
| `Accept` | No | Set to `application/json` (default) |

### Response

**Status Code:** `200 OK`

```json
{
  "id": "org123",
  "name": "Acme Corporation",
  "active": 1,
  "rec_create": "2024-01-15T10:30:00Z",
  "rec_update": "2025-12-20T14:22:00Z",
  "active_users_count": 5,
  "owner_email": "owner@acmecorp.com"
}
```

### Error Responses

#### 404 Not Found
Account with specified ID does not exist or doesn't belong to the current agency.

```json
{
  "name": "Not Found",
  "message": "Object not found: org999",
  "code": 0,
  "status": 404
}
```

### Examples

**cURL:**
```bash
curl -X GET "https://admin-api.apolloscheduling.com/v1/account/org123" \
  -H "Accept: application/json"
```

**JavaScript (Fetch):**
```javascript
const accountId = 'org123';
const response = await fetch(
  `https://admin-api.apolloscheduling.com/v1/account/${accountId}`
);

if (response.ok) {
  const account = await response.json();
  console.log(`Account: ${account.name}`);
  console.log(`Active Users: ${account.active_users_count}`);
  console.log(`Owner: ${account.owner_email}`);
} else if (response.status === 404) {
  console.error('Account not found');
}
```

**TypeScript (Axios):**
```typescript
import axios from 'axios';

async function getAccount(accountId: string): Promise<Account | null> {
  try {
    const response = await axios.get<Account>(
      `https://admin-api.apolloscheduling.com/v1/account/${accountId}`
    );
    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      console.error(`Account ${accountId} not found`);
      return null;
    }
    throw error;
  }
}

const account = await getAccount('org123');
if (account) {
  console.log(account.name);
}
```

---

## GET /v1/agency/service-connections

List user-level service connection status and recent connect/disconnect events for an agency.

**URL:** `/v1/agency/service-connections`  
**Method:** `GET`  
**Authentication:** Required

### Query Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `agency_id` | integer | Yes | — | Agency ID to scope results |
| `page` | integer | No | 1 | Page number |
| `per_page` | integer | No | 25 | Items per page (max 100) |
| `service_id` | string | No | — | Service ID or comma-separated list (e.g. `google,outlookoffice`) |
| `status` | string | No | — | One of `connected`, `disconnected`, `enabled`, `disabled` |
| `start_date` | string | No | — | ISO date/time; filters `last_action_at >= start_date` |
| `end_date` | string | No | — | ISO date/time; filters `last_action_at <= end_date` |

### Response

**Status Code:** `200 OK`

```json
{
  "items": [
    {
      "user_id": 314,
      "email": "john@example.com",
      "entity_has_service_id": 221,
      "service_id": "google",
      "enabled": 1,
      "is_connected": 1,
      "disconnected_on": null,
      "disconnect_reason": null,
      "keap_app_alias": null,
      "last_action": "user connect",
      "last_action_at": "2025-12-20T09:15:00Z"
    }
  ],
  "total": 120,
  "page": 1,
  "per_page": 25
}
```

### Error Responses

#### 400 Bad Request
`agency_id` is required.

```json
{
  "message": "agency_id is required."
}
```

#### 403 Forbidden
Admin does not have access to the agency.

```json
{
  "message": "Access denied."
}
```

---

## GET /v1/agency/user-changes

Report when users were added to or removed from partner accounts. Supports date-range filtering for agency billing audits.

**URL:** `/v1/agency/user-changes`  
**Method:** `GET`  
**Authentication:** Required

### Query Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `agency_id` | integer | No | — | Agency ID to scope results |
| `organization_id` | string | No | — | Limit to a single partner account |
| `page` | integer | No | 1 | Page number |
| `per_page` | integer | No | 25 | Items per page (max 100) |
| `start_date` | string | No | — | ISO date/time; filters change date `>= start_date` |
| `end_date` | string | No | — | ISO date/time; filters change date `<= end_date` |
| `change_type` | string | No | — | One of `added`, `removed`, `reactivated` |
| `grouped` | integer | No | 1 | `1` aggregates by account/date/type; `0` returns per-user rows |

### Response

**Status Code:** `200 OK`

```json
{
  "items": [
    {
      "organization_id": "acme_org",
      "account_name": "Acme Corp",
      "change_date": "2025-11-15",
      "change_type": "added",
      "user_count": 2,
      "active_users_after": 5,
      "actor_type": "admin_user",
      "actor_id": 42,
      "actor_email": "billing@example.com",
      "source": "admin_api"
    }
  ],
  "total": 18,
  "page": 1,
  "per_page": 25,
  "grouped": true,
  "data_source": "organization_user_change_log"
}
```

`active_users_after` is the reconstructed active seat count for the account after the change (end of day in grouped mode; at `change_at` in detail mode).

**📖 Full Documentation:** [Agency User Changes Documentation](./api/agency/user-changes.md)

### Error Responses

#### 400 Bad Request
Invalid `change_type`.

#### 403 Forbidden
Admin does not have access to the agency.

#### 404 Not Found
Organization does not belong to the agency.

---

### Admins

Admin users can be listed, created, and deactivated. Super admins can list/create/deactivate for any agency; agency admins can only act within their own agency(ies). Created admins can have role `admin` or `joint_venture_partner`.

---

## GET /v1/admins

List admin users with optional filters (email, active, agency_id, role). Paginated.

**URL:** `/v1/admins`  
**Method:** `GET`  
**Authentication:** Required

### Query Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `email` | string | No | — | Partial match on admin email |
| `active` | integer | No | — | `0` or `1` |
| `agency_id` | integer | No | — | Filter by agency (agency admins may only use their agencies) |
| `role` | string | No | — | Filter by role: `admin` or `joint_venture_partner` |
| `page` | integer | No | 1 | Page number |
| `per_page` | integer | No | 25 | Items per page (max 100) |

### Response

**Status Code:** `200 OK`

```json
{
  "items": [
    {
      "id": 5,
      "email": "jvp@example.com",
      "active": 1,
      "rec_create": "2025-01-15 10:00:00",
      "rec_update": "2025-01-15 10:00:00",
      "agency_id": 1,
      "agency_name": "Acme Agency",
      "role_id": 2,
      "role_name": "joint_venture_partner"
    }
  ],
  "total": 42,
  "page": 1,
  "per_page": 25
}
```

### Error Responses

#### 401 Unauthorized
Invalid or missing credentials.

#### 403 Forbidden
No agency access, or agency admin filtered by an agency they don't belong to.

---

## POST /v1/admins

Create an admin user with role and agency link. Super admins can create for any agency; agency admins only for their own agency(ies).

**URL:** `/v1/admins`  
**Method:** `POST`  
**Authentication:** Required

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | Yes | Admin email (unique) |
| `password` | string | No | Plain password; if omitted, password is NULL until set later |
| `active` | integer | No | Default `1` |
| `agency_id` | integer | Yes | Agency to link; must be one the current user has access to |
| `role` | string | No | `admin` or `joint_venture_partner` (default `joint_venture_partner`) |

### Response

**Status Code:** `201 Created`

```json
{
  "id": 12,
  "email": "newadmin@example.com",
  "active": 1,
  "agency_id": 1,
  "role": "joint_venture_partner"
}
```

### Error Responses

#### 400 Bad Request
Missing email, duplicate email, invalid agency_id, invalid role, or agency not found.

#### 403 Forbidden
No agency access, or agency admin creating for an agency they don't belong to.

#### 500 Internal Server Error
Role not configured or database save failure.

---

## PATCH /v1/admins/{id}/profile

Update an admin user's email and/or agency name in one request. At least one of `email` or `agency_name` is required. When `agency_name` is sent and the admin belongs to multiple agencies, `agency_id` is required.

**URL:** `/v1/admins/{id}/profile`  
**Method:** `PATCH`  
**Authentication:** Required

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | No* | New admin email |
| `agency_name` | string | No* | New agency display name |
| `agency_id` | integer | Conditional | Required with `agency_name` when admin has multiple agencies |

\* At least one of `email` or `agency_name`.

### Response

**Status Code:** `200 OK`

```json
{
  "id": 74,
  "email": "admin@example.com",
  "agency_id": 1,
  "agency_name": "Acme Agency"
}
```

### Error Responses

Same as email update, plus agency-specific 400/403/404 cases.

---

## PATCH /v1/admins/{id}/email

Update an admin user's email only. Super admins can update any admin; agency admins only admins in their agency(ies). Admins may update their own email.

**URL:** `/v1/admins/{id}/email`  
**Method:** `PATCH`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | Admin user ID |

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | Yes | New email (unique) |

### Response

**Status Code:** `200 OK`

```json
{
  "id": 5,
  "email": "newadmin@example.com"
}
```

### Error Responses

#### 400 Bad Request
Missing/invalid email or duplicate email.

#### 403 Forbidden
No agency access, or target not in caller's agencies (unless super admin or self).

#### 404 Not Found

#### 500 Internal Server Error

---

## PATCH /v1/admins/{id}/password

Update an admin user's password. Super admins can update any admin; agency admins only admins in their agency(ies). Admins may update their own password.

**URL:** `/v1/admins/{id}/password`  
**Method:** `PATCH`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | Admin user ID |

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `password` | string | Yes | New plain-text password (non-empty) |

### Response

**Status Code:** `200 OK`

```json
{
  "id": 5
}
```

### Error Responses

#### 400 Bad Request
Missing or empty password.

#### 403 Forbidden
No agency access, or target not in caller's agencies (unless super admin or self).

#### 404 Not Found

#### 500 Internal Server Error

---

## PATCH /v1/admins/{id}

Deactivate an admin user (set `active = 0`). Super admins can deactivate any admin; agency admins only admins in their agency(ies). You cannot deactivate yourself.

**URL:** `/v1/admins/{id}`  
**Method:** `PATCH`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | Admin user ID to deactivate |

### Response

**Status Code:** `200 OK`

```json
{
  "id": 5,
  "email": "jvp@example.com",
  "active": 0
}
```

### Error Responses

#### 400 Bad Request
Attempt to deactivate your own account.

#### 401 Unauthorized
Invalid or missing credentials.

#### 403 Forbidden
No agency access, or agency admin deactivating an admin not in their agency(ies).

#### 404 Not Found
Admin not found.

#### 500 Internal Server Error
Failed to save.

---

## PATCH /v1/user/{id}/email

Update an Appcore user's email (`user.email`, `user.username`, and all Appcore `entity_has_service` rows for that user).

**URL:** `/v1/user/{id}/email`  
**Method:** `PATCH`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | User ID |

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | Yes* | New email |
| `pri_email` | string | Yes* | Alias for `email` |

### Response

**Status Code:** `200 OK`

```json
{
  "user_id": 12345,
  "email": "user@example.com"
}
```

### Error Responses

#### 400 Bad Request
#### 403 Forbidden
#### 404 Not Found
#### 409 Conflict — email already used by another Appcore user
#### 500 Internal Server Error

---

## GET /v1/user/{id}/service-connections

List user-level service connection status and recent connect/disconnect events for a user.

**URL:** `/v1/user/{id}/service-connections`  
**Method:** `GET`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | User ID |

### Response

**Status Code:** `200 OK`

```json
[
  {
    "connection_id": 220,
    "user_id": 314,
    "service_id": "google",
    "service_name": "Google Calendar",
    "is_connected": 1,
    "disconnected_on": null,
    "disconnect_reason": null,
    "keap_app_alias": null
  }
]
```

### Error Responses

#### 404 Not Found
User with specified ID does not exist or is outside agency scope.

```json
{
  "name": "Not Found",
  "message": "User not found",
  "code": 0,
  "status": 404
}
```

---

## GET /v1/user/{userId}/service-history/{serviceId}

Get paginated history of service connection/disconnection events for a specific user and service.

**URL:** `/v1/user/{userId}/service-history/{serviceId}`  
**Method:** `GET`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `userId` | integer | Yes | User ID |
| `serviceId` | string | Yes | Service identifier (google, outlookoffice, exchange, infusionsoft) |

### Query Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `per_page` | integer | No | 25 | Items per page (max: 100) |
| `page` | integer | No | 1 | Page number |

### Response

**Status Code:** `200 OK`

```json
{
  "items": [
    {
      "id": 587,
      "service_id": "google",
      "action": "user connect",
      "organization_id": null,
      "user_id": 828,
      "meta_data": {
        "entity_has_service_id": 29249,
        "service_username": "nick@appointmentcore.com",
        "auth_type": "oauth2"
      },
      "created_at": "2025-12-30 13:41:27"
    },
    {
      "id": 586,
      "service_id": "google",
      "action": "user disconnect",
      "organization_id": null,
      "user_id": 828,
      "meta_data": {
        "entity_has_service_id": 29249,
        "reason": "Token expired",
        "disconnect_type": "forced"
      },
      "created_at": "2025-12-29 10:15:30"
    }
  ],
  "total": 47,
  "page": 1,
  "per_page": 25
}
```

### Response Fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Log entry ID |
| `service_id` | string | Service identifier (google, outlookoffice, exchange, infusionsoft) |
| `action` | string | Connection action (e.g., "user connect", "user disconnect", "forced disconnect") |
| `organization_id` | integer\|null | Organization ID (for org-level connections) |
| `user_id` | integer\|null | User ID (for user-level connections) |
| `meta_data` | string | JSON string containing additional service-specific metadata |
| `created_at` | string | Event timestamp |
| `total` | integer | Total number of records |
| `page` | integer | Current page number |
| `per_page` | integer | Items per page |

**Note:** Results are ordered by `created_at DESC` (most recent events first).

### Error Responses

#### 404 Not Found
User with specified ID does not exist or is outside agency scope.

```json
{
  "name": "Not Found",
  "message": "User not found",
  "code": 0,
  "status": 404
}
```

---

## Data Models

### Account Object

Represents an organization/account in the system.

```typescript
interface Account {
  id: string;                    // Max 50 characters
  name: string;                  // Max 255 characters
  active: 0 | 1;                 // 0 = inactive, 1 = active
  rec_create: string;            // ISO 8601 timestamp
  rec_update: string;            // ISO 8601 timestamp
  active_users_count: number;    // Computed field
  owner_email: string;           // Computed field
}
```

### Paginated Response

Standard structure for paginated list responses.

```typescript
interface PaginatedResponse<T> {
  items: T[];           // Array of objects
  total: number;        // Total count matching filters
  page: number;         // Current page number (1-based)
  per_page: number;     // Items per page
}
```

### Error Response

Standard error response structure.

```typescript
interface ErrorResponse {
  name: string;       // Error type (e.g., "Bad Request", "Not Found")
  message: string;    // Human-readable error message
  code: number;       // Application-specific error code (0 for generic errors)
  status: number;     // HTTP status code
}
```

---

## Common Patterns

### Error Handling

Always check the response status before processing data:

```javascript
async function fetchAccounts() {
  try {
    const response = await fetch('https://admin-api.apolloscheduling.com/v1/account');
    
    if (!response.ok) {
      const error = await response.json();
      throw new Error(`API Error: ${error.message}`);
    }
    
    return await response.json();
  } catch (error) {
    console.error('Failed to fetch accounts:', error);
    throw error;
  }
}
```

### Building Complex Queries

Use URLSearchParams for clean query string construction:

```javascript
function buildAccountsUrl(filters) {
  const params = new URLSearchParams();
  
  if (filters.active !== undefined) {
    params.set('active', filters.active);
  }
  if (filters.name) {
    params.set('name', filters.name);
  }
  if (filters.sort) {
    params.set('sort', filters.sort);
  }
  if (filters.page) {
    params.set('page', filters.page);
  }
  if (filters.perPage) {
    params.set('per_page', filters.perPage);
  }
  
  const queryString = params.toString();
  return `https://admin-api.apolloscheduling.com/v1/account${queryString ? '?' + queryString : ''}`;
}

// Usage
const url = buildAccountsUrl({
  active: 1,
  name: 'tech',
  sort: '-rec_update',
  page: 1,
  perPage: 50
});
```

---

## POST /v1/account

**Status:** 🚧 Planned - Not yet implemented

Create a new account.

**URL:** `/v1/account`  
**Method:** `POST`  
**Authentication:** Required (when implemented)

### Request Body

```json
{
  "id": "org456",
  "name": "New Corporation",
  "active": 1
}
```

### Response

**Status Code:** `201 Created`

```json
{
  "id": "org456",
  "name": "New Corporation",
  "active": 1,
  "rec_create": "2025-12-22T10:30:00Z",
  "rec_update": "2025-12-22T10:30:00Z",
  "active_users_count": 0,
  "owner_email": null
}
```

---

## PUT /v1/account/{id}

**Status:** 🚧 Planned - Not yet implemented

Update an existing account.

**URL:** `/v1/account/{id}`  
**Method:** `PUT`  
**Authentication:** Required (when implemented)

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The unique organization identifier |

### Request Body

```json
{
  "name": "Updated Corporation Name",
  "active": 0
}
```

### Response

**Status Code:** `200 OK`

```json
{
  "id": "org456",
  "name": "Updated Corporation Name",
  "active": 0,
  "rec_create": "2025-12-22T10:30:00Z",
  "rec_update": "2025-12-22T15:45:00Z",
  "active_users_count": 0,
  "owner_email": null
}
```

---

## POST /v1/account/{id}/users

**Status:** ✅ Implemented

Create a new user inside an organization. This follows the legacy Appcore user creation flow and wires up contact, service, calendar, and organization membership.

**📖 Full Documentation:** [Create Account User Documentation](./api/accounts/create-user.md)

**URL:** `/v1/account/{id}/users`  
**Method:** `POST`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The unique organization identifier |

### Request Body

```json
{
  "pri_email": "user@example.com",
  "password": "S3cret!",
  "fname": "Ava",
  "lname": "Nguyen",
  "company": "Acme Corp",
  "roles": ["user", "manager"],
  "pref": {
    "timezone": "America/Los_Angeles"
  },
  "force_password_change": false
}
```

### Response

**Status Code:** `201 Created`

```json
{
  "user_id": 12345,
  "user_in_org_id": 67890,
  "contact_id": 13579,
  "email": "user@example.com"
}
```

---

## PATCH /v1/account/{id}/users/{userId}

**Status:** ✅ Implemented

Update an existing user inside an organization. This updates the legacy Appcore user record, self contact fields, Appcore service credentials, organization membership status, and roles.

**📖 Full Documentation:** [Update Account User Documentation](./api/accounts/update-user.md)

**URL:** `/v1/account/{id}/users/{userId}`  
**Method:** `PATCH`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The unique organization identifier |
| `userId` | integer | Yes | The user identifier |

### Request Body

```json
{
  "pri_email": "user@example.com",
  "password": "S3cret!",
  "fname": "Ava",
  "lname": "Nguyen",
  "company": "Acme Corp",
  "roles": ["user", "manager"],
  "pref": {
    "timezone": "America/Los_Angeles"
  },
  "force_password_change": false,
  "active": 1
}
```

When `name` is provided (without `fname`/`lname`), use `Last, First` (e.g. `Perez, Juan`) or `First Last` (e.g. `Juan Perez`).

### Response

**Status Code:** `200 OK`

```json
{
  "user_id": 12345,
  "user_in_org_id": 67890,
  "contact_id": 13579,
  "email": "user@example.com"
}
```

---

## GET /v1/account/{id}/booking-link-templates

**Status:** ✅ Implemented

List booking link templates applicable to an organization (matched by edition + **subscription/plan**). Includes execution history and subscription scopes.

**📖 Full Documentation:** [Booking Link Templates Documentation](./api/accounts/booking-link-templates.md)

**URL:** `/v1/account/{id}/booking-link-templates`  
**Method:** `GET`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The unique organization identifier |

### Response

**Status Code:** `200 OK`

```json
{
  "organization_id": "acme-corp",
  "edition_id": "ac2",
  "subscription_id": "ac_team",
  "templates": [
    {
      "id": 123,
      "title": "Standard Booking Link",
      "description": "Default booking link for team-plan users",
      "template": "booking-link-standard",
      "interval": "P0DT0H0M",
      "command": "bookingLink.copy",
      "active": 1,
      "all_editions": 0,
      "all_subscriptions": 0,
      "scopes": {
        "subscriptions": {
          "include": [{ "subscription_id": "ac_team" }],
          "exclude": []
        }
      },
      "execution_history": {
        "last_executed": "2025-12-23 14:30:00",
        "total_users": 25,
        "successful_users": 25,
        "last_executed_by": 42
      }
    }
  ]
}
```

---

## POST /v1/account/{id}/booking-links

**Status:** ✅ Implemented

Create booking links from a template for users in an organization. Queues creation for background processing.

**📖 Full Documentation:** [Create Booking Links Documentation](./api/accounts/create-booking-links.md)

**URL:** `/v1/account/{id}/booking-links`  
**Method:** `POST`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | Yes | The unique organization identifier |

### Request Body

```json
{
  "template_id": 123,
  "user_ids": [456, 789],
  "parameters": {
    "custom_field": "value"
  }
}
```

**Required:** `template_id`  
**Optional:** `user_ids` (if empty, applies to all active users), `parameters`

### Response

**Status Code:** `200 OK`

```json
{
  "message": "Booking link creation queued successfully.",
  "created_count": 2,
  "links": [
    {
      "user_id": 456,
      "uio_id": 12345,
      "trigger_event_id": 98765
    },
    {
      "user_id": 789,
      "uio_id": 12346,
      "trigger_event_id": 98766
    }
  ],
  "organization_id": "acme-corp",
  "template_id": 123
}
```

---

## Booking Link Copy Triggers

**Status:** ✅ Implemented

CRUD for `bookingLink.copy` edition trigger definitions (BrandBuilder templates). Execution against accounts remains on the account booking-link endpoints above.

**📖 Full Documentation:** [Booking Link Copy Triggers](./api/triggers/booking-link-copy.md)

| Method | URL | Auth |
|--------|-----|------|
| `GET` | `/v1/triggers` | Admin |
| `GET` | `/v1/triggers/{id}` | Admin |
| `POST` | `/v1/triggers` | Super admin |
| `PATCH` | `/v1/triggers/{id}` | Super admin |
| `DELETE` | `/v1/triggers/{id}` | Super admin |
| `POST` | `/v1/triggers/{id}/duplicate` | Super admin |
| `POST` | `/v1/triggers/resolve-booking-link` | Admin |
| `GET` | `/v1/trigger-categories` | Admin |
| `POST` | `/v1/trigger-categories` | Super admin |

---

## Users

User management endpoints for listing and viewing users within an agency. Supports comprehensive filtering, including calendar integration status, UI versions, and advanced search syntax.

**📖 Full Documentation:** [Users Endpoint Documentation](./api/endpoints/users.md)
**📖 Appointments Endpoint Documentation:** [User Appointments](./api/users/appointments.md)

---

## Appointments

Agency-wide appointment history for users within the logged-in agency, with filtering, sorting, and pagination.

**📖 Full Documentation:** [Agency Appointments](./api/appointments/list.md)

### GET /v1/user

**Status:** ✅ Implemented

List all users for the current agency with advanced filtering and pagination.

**URL:** `/v1/user`  
**Method:** `GET`  
**Authentication:** Required

### Query Parameters

#### Basic Filters
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `id` | string | No | - | Filter by user ID (partial match) |
| `email` | string | No | - | Filter by email. Supports special syntax: `uid:123`, `uio:456` |
| `name` | string | No | - | Filter by first or last name (partial match) |
| `active` | integer | No | - | Filter by active status (0 = inactive, 1 = active) |
| `organization_id` | string | No | - | Filter by organization ID |
| `organization_name` | string | No | - | Filter by org name. Supports `id:org123` syntax |
| `page` | integer | No | 1 | Page number for pagination |
| `per_page` | integer | No | 25 | Items per page (max: 100) |
| `sort` | string | No | `-last_activity` | Sort field. Prefix with `-` for descending |

**Allowed sort fields:** `id`, `rec_create`, `last_activity`, `last_booking`

#### Advanced Filters
| Parameter | Type | Description |
|-----------|------|-------------|
| `ui_version` | integer | Filter by UI version (2 or 3) |
| `booking_links_version` | integer | Filter by booking links version (2 or 3) |
| `infusionsoft_status` | string | `connected`, `forced_disconnect`, `not_connected` |
| `outlook_status` | string | `connected`, `forced_disconnect`, `not_connected`, `graph_connected`, `graph_not_connected` |
| `exchange_status` | string | Same as outlook_status |
| `google_status` | string | `connected`, `forced_disconnect`, `not_connected` |
| `org_infusionsoft` | integer | Organization-level Infusionsoft (0 or 1) |
| `fast` | integer | Enable fast mode (1) to skip heavy computations |
| `expand` | string | Comma-separated: `roles`, `calendar_integrations` |

### Response

**Status Code:** `200 OK`

```json
{
  "items": [
    {
      "id": 314,
      "uio_id": 313,
      "email": "john@example.com",
      "name": "Doe, John",
      "active": 1,
      "organization_id": "org456",
      "organization_name": "Acme Corporation",
      "last_activity": "2025-12-22T14:30:00Z",
      "rec_create": "2024-06-10T08:00:00Z",
      "ui_version": 3,
      "booking_links_version": 3,
      "last_booking": "2025-12-20T09:15:00Z"
    }
  ],
  "total": 234,
  "page": 1,
  "per_page": 25
}
```

**Response Fields:**

#### User Items
- `id` (integer) - User ID
- `uio_id` (integer) - User in organization ID
- `email` (string) - User's email address
- `name` (string) - Full name ("Last, First" format)
- `active` (integer) - Active status (0 or 1)
- `organization_id` (string) - Organization ID
- `organization_name` (string) - Organization name
- `last_activity` (string|null) - Last activity timestamp
- `rec_create` (string) - Record creation timestamp
- `ui_version` (integer|null) - UI version *(omitted in fast mode)*
- `booking_links_version` (integer|null) - Booking links version *(omitted in fast mode)*
- `last_booking` (string|null) - Last booking timestamp *(omitted in fast mode)*

#### Pagination Fields
- `items` (array) - Array of user objects
- `total` (integer) - Total number of users matching the filters
- `page` (integer) - Current page number (1-based)
- `per_page` (integer) - Number of items per page

---

## GET /v1/user/{id}

**Status:** ✅ Implemented

Get detailed information for a single user by ID.

**URL:** `/v1/user/{id}`  
**Method:** `GET`  
**Authentication:** Required

### URL Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | integer | Yes | The user ID |

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `fast` | integer | 0 | Enable fast mode to skip heavy fields |
| `expand` | string | `calendar_integrations,roles` | Fields to expand |

### Response

**Status Code:** `200 OK`

```json
{
  "id": 314,
  "uio_id": 313,
  "email": "john@example.com",
  "name": "Doe, John",
  "active": 1,
  "organization_id": "org456",
  "organization_name": "Acme Corporation",
  "last_activity": "2025-12-22T14:30:00Z",
  "rec_create": "2024-06-10T08:00:00Z",
  "ui_version": 3,
  "booking_links_version": 3,
  "last_booking": "2025-12-20T09:15:00Z",
  "roles": [
    "admin",
    "user",
    "org_create_appt_request"
  ],
  "calendar_integrations": [
    {
      "service": "infusionsoft",
      "status": "connected"
    },
    {
      "service": "outlook",
      "status": "graph_connected",
      "using_graph_api": true
    },
    {
      "service": "exchange",
      "status": "not_connected"
    },
    {
      "service": "google",
      "status": "connected"
    }
  ]
}
```

---

## POST /v1/user/export

**Status:** 🚧 Planned - Not yet implemented

Request a CSV export of users data. This endpoint initiates a background job and sends a notification when the export is complete.

**URL:** `/v1/user/export`  
**Method:** `POST`  
**Authentication:** Required (when implemented)

### Request Body

```json
{
  "filters": {
    "organization_id": "org456",
    "active": 1
  },
  "fields": [
    "id",
    "email",
    "first_name",
    "last_name",
    "organization_id",
    "active"
  ],
  "notification_email": "admin@example.com"
}
```

### Response

**Status Code:** `202 Accepted`

```json
{
  "job_id": "export-20251222-abc123",
  "status": "queued",
  "message": "Export job has been queued. You will receive an email at admin@example.com when the export is ready.",
  "estimated_time": "5-10 minutes"
}
```

### Export Job Status

Check the status of an export job:

**URL:** `/v1/user/export/{job_id}`  
**Method:** `GET`

**Response:**

```json
{
  "job_id": "export-20251222-abc123",
  "status": "completed",
  "created_at": "2025-12-22T14:30:00Z",
  "completed_at": "2025-12-22T14:32:15Z",
  "download_url": "https://admin-api.apolloscheduling.com/downloads/export-20251222-abc123.csv",
  "expires_at": "2025-12-23T14:32:15Z"
}
```

**Status values:** `queued`, `processing`, `completed`, `failed`

---

## Authentication

### POST /auth/login

**Status:** ✅ Implemented

Authenticate and receive an access token.

**URL:** `/auth/login`  
**Method:** `POST`  
**Authentication:** Not required

### Request Body

```json
{
  "email": "admin@example.com",
  "password": "secure_password"
}
```

### Response

**Status Code:** `200 OK`

```json
{
  "access_token": "<jwt-token>",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "<64-character-hex-token>",
  "scope": null,
  "admin_user_id": 123,
  "role_id": 2
}
```

**Response Fields:**
- `access_token`: JWT token for authenticating API requests
- `token_type`: Always "Bearer"
- `expires_in`: Token validity in seconds (default: 86400 = 24 hours)
- `refresh_token`: Token for obtaining new access tokens via `/auth/refresh`
- `scope`: OAuth scope (currently null)
- `admin_user_id`: ID of the authenticated admin user
- `role_id`: Primary role ID (optional, only if user has a role)

### Error Response

**Status Code:** `400 Bad Request`

```json
{
  "message": "Email and password are required."
}
```

**Status Code:** `401 Unauthorized`

```json
{
  "message": "Invalid credentials."
}
```

---

## POST /auth/refresh

**Status:** ✅ Implemented

Obtain a new access token using a refresh token without re-authenticating.

**URL:** `/auth/refresh`  
**Method:** `POST`  
**Authentication:** Not required

### Request Body

```json
{
  "grant_type": "refresh_token",
  "refresh_token": "<64-character-hex-token>"
}
```

**Required Fields:**
- `grant_type`: Must be exactly "refresh_token"
- `refresh_token`: The refresh token received from `/auth/login`

### Response

**Status Code:** `200 OK`

```json
{
  "access_token": "<new-jwt-token>",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "<new-64-character-hex-token>",
  "scope": null
}
```

**Note:** The old refresh token is invalidated and a new one is issued. Store the new refresh token for future use.

### Error Responses

**Status Code:** `400 Bad Request`

```json
{
  "error": "invalid_grant",
  "message": "Invalid grant_type."
}
```

```json
{
  "error": "invalid_request",
  "message": "refresh_token is required."
}
```

**Status Code:** `401 Unauthorized`

```json
{
  "error": "invalid_refresh_token",
  "message": "Refresh token not found."
}
```

```json
{
  "error": "refresh_token_expired",
  "message": "Refresh token expired."
}
```

```json
{
  "error": "invalid_refresh_token",
  "message": "Invalid refresh token."
}
```

---

## POST /auth/logout

**Status:** 🚧 Planned - Not yet implemented

Invalidate the current access token.

**URL:** `/auth/logout`  
**Method:** `POST`  
**Authentication:** Required

### Request Headers

| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes | Bearer token: `Bearer {access_token}` |

### Response

**Status Code:** `204 No Content`

---

## Changelog

### v1.0.1 (2025-12-31)
- **Breaking Change:** Standardized list endpoint response structure
  - All list endpoints now return `{items, total, page, per_page}` format
  - Removed `_meta` and `_links` objects for simpler integration
  - Applies to: `GET /v1/account`, `GET /v1/user`, and future list endpoints
- Updated all documentation and code examples

### v1.0.0 (2025-12-22)
- Initial release
- Added `GET /v1/account` endpoint with filtering, pagination, and sorting
- Added `GET /v1/account/{id}` endpoint for single account retrieval
- Computed fields: `active_users_count` and `owner_email`
