# Volubit CMS API Documentation

> **Base URL:** `/api` (e.g., `https://api.volubit.io/api`)
>
> **API Version:** v1
>
> **Date:** August 2026

---

## Table of Contents

1. [Conventions](#1-conventions)
2. [Public API](#2-public-api)
3. [Auth API](#3-auth-api)
4. [Admin API](#4-admin-api)
5. [Data Models](#5-data-models)
6. [Block Types & Pages](#6-block-types--pages)
7. [Webhooks](#7-webhooks)
8. [Environment Variables](#8-environment-variables)
9. [FE Integration Guide](#9-fe-integration-guide)

---

## 1. Conventions

### 1.1 Response Envelope

**Success:**

```json
{
  "success": true,
  "data": { ... }
}
```

**Success with pagination:**

```json
{
  "success": true,
  "data": [ ... ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "total": 25,
    "last_page": 3
  }
}
```

**Cached response (public endpoints only):**

```json
{
  "success": true,
  "data": { ... },
  "cachedAt": "2026-08-23T10:00:00+07:00"
}
```

**Error:**

```json
{
    "success": false,
    "error": {
        "code": "NOT_FOUND",
        "message": "Resource not found."
    }
}
```

**Validation error (422):**

```json
{
    "message": "The given data was invalid.",
    "errors": {
        "email": ["The email field is required."],
        "password": ["The password must be at least 8 characters."]
    }
}
```

### 1.2 Authentication

Three auth mechanisms depending on the route group:

| Route Group                | Auth Method                | How to Pass                     |
| -------------------------- | -------------------------- | ------------------------------- |
| Public API                 | None                       | —                               |
| Auth API (`/api/auth/*`)   | Session-based (`auth:web`) | Session cookie (CSRF protected) |
| Admin API (`/api/admin/*`) | Static API Token           | `X-Api-Token` request header    |

**Admin API token** is configured via the `CMS_ADMIN_API_TOKEN` env var.

> **Note:** The admin middleware currently authenticates as the first active `AdminUser` found in the database (shared token model). Per-user audit attribution uses that user's ID.

### 1.3 Rate Limiting

| Throttle     | Limit       | Key    |
| ------------ | ----------- | ------ |
| `public-api` | 60 req/min  | per IP |
| `admin-api`  | 300 req/min | per IP |
| `auth-login` | 10 req/min  | per IP |

Returns `429 Too Many Requests` with a `Retry-After` header when exceeded.

### 1.4 Content Cache

Public endpoints cache responses for **5 minutes** (300 seconds). Cached responses include a `cachedAt` ISO timestamp. Cache is stored in the file store (`bootstrap/cache/data/`).

### 1.5 HTML Sanitization

All HTML in `ContentBlock.data`, `Article.content`, and `Article.excerpt` is sanitized server-side before output. Only these tags are preserved:

```
<p>, <br>, <strong>, <b>, <em>, <i>, <u>, <a>, <ul>, <ol>, <li>,
<h1>, <h2>, <h3>, <h4>, <blockquote>, <code>, <pre>, <span>, <div>,
<table>, <thead>, <tbody>, <tr>, <th>, <td>
```

All other tags and attributes are stripped.

---

## 2. Public API

No authentication required. Rate limited at 60 req/min per IP.

---

### 2.1 Content

#### `GET /api/content/{page}`

Fetch all content blocks for a page. Cached 5 minutes.

**Path Parameters**

| Param  | Type   | Description                                                      |
| ------ | ------ | ---------------------------------------------------------------- |
| `page` | string | Page key. See [Section 6.1](#61-valid-page-keys) for all values. |

**Response `200`**

```json
{
    "success": true,
    "data": {
        "hero": {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "page": "home",
            "section": "hero",
            "blockType": "HeroBlock",
            "locale": "en",
            "data": {
                "eyebrow": "186 members online right now",
                "headline": "Learn crypto with people who actually do it.",
                "description": "Volubit is a members-only education community...",
                "ctaPrimary": {
                    "label": "Become a member",
                    "href": "/checkout"
                },
                "ctaSecondary": {
                    "label": "Explore the community",
                    "href": "/community"
                },
                "stats": [
                    { "value": 5200, "suffix": "+", "label": "Members" },
                    { "value": 180, "suffix": "+", "label": "Research notes" },
                    { "value": 96, "suffix": "%", "label": "Renew yearly" }
                ],
                "trustedBy": ["Binance", "CoinGecko", "CryptoQuant", "Dune"]
            },
            "updatedAt": "2026-08-20T14:30:00+07:00",
            "updatedBy": "Admin Name"
        }
    },
    "cachedAt": "2026-08-23T10:00:00+07:00"
}
```

> **Data shape varies by block type.** See [Section 6.2](#62-block-type-data-shapes) for the full data reference for each block type.

---

#### `GET /api/content/{page}/{section}`

Fetch a single content block by section name. Cached 5 minutes.

**Path Parameters**

| Param     | Type   | Description                                          |
| --------- | ------ | ---------------------------------------------------- |
| `page`    | string | Page key                                             |
| `section` | string | Section key within the page (e.g. `hero`, `gallery`) |

**Response `200`** — Single `ContentBlockResource` object

```json
{
  "success": true,
  "data": {
    "id": "...",
    "page": "home",
    "section": "hero",
    "blockType": "HeroBlock",
    "locale": "en",
    "data": { ... },
    "updatedAt": "2026-08-20T14:30:00+07:00",
    "updatedBy": "Admin Name"
  },
  "cachedAt": "2026-08-23T10:00:00+07:00"
}
```

**Response `404`**

```json
{
    "success": false,
    "error": {
        "code": "NOT_FOUND",
        "message": "Section 'hero' not found on page 'home'."
    }
}
```

---

### 2.2 Site Settings

#### `GET /api/site-settings`

Fetch all public site settings as key-value pairs. Cached 5 minutes.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "nav": {
            "items": [
                { "label": "Home", "href": "/" },
                {
                    "label": "Membership",
                    "href": "/membership",
                    "children": [
                        { "label": "Membership Detail", "href": "/membership" },
                        { "label": "Pricing", "href": "/membership/pricing" },
                        { "label": "Checkout", "href": "/checkout" }
                    ]
                },
                {
                    "label": "Events",
                    "href": "/events",
                    "children": [
                        { "label": "Upcoming", "href": "/events" },
                        { "label": "Past Events", "href": "/events/past" }
                    ]
                },
                { "label": "Articles", "href": "/articles" }
            ]
        },
        "footer": {
            "footerDescription": "A premium crypto education community. Research, mentorship and a room full of people learning in public.",
            "footerCopyright": "© {year} Volubit. Educational content only, not financial advice.",
            "footerLinks": {
                "columns": [
                    {
                        "heading": "Platform",
                        "links": [
                            { "label": "Membership", "href": "/membership" },
                            {
                                "label": "Pricing",
                                "href": "/membership/pricing"
                            },
                            { "label": "Community", "href": "/community" },
                            { "label": "Events", "href": "/events" }
                        ]
                    },
                    {
                        "heading": "Resources",
                        "links": [
                            { "label": "Articles", "href": "/articles" },
                            { "label": "FAQ", "href": "/faq" },
                            { "label": "Partnership", "href": "/partnership" },
                            { "label": "About", "href": "/about" }
                        ]
                    },
                    {
                        "heading": "Contact",
                        "links": [
                            { "label": "Contact us", "href": "/contact" },
                            { "label": "Checkout", "href": "/checkout" }
                        ]
                    }
                ],
                "socialLinks": [
                    {
                        "platform": "Discord",
                        "href": "https://discord.gg/volubit"
                    },
                    { "platform": "Telegram", "href": "https://t.me/volubit" },
                    { "platform": "X", "href": "https://x.com/volubit" },
                    {
                        "platform": "LinkedIn",
                        "href": "https://linkedin.com/company/volubit"
                    },
                    {
                        "platform": "YouTube",
                        "href": "https://youtube.com/@volubit"
                    }
                ],
                "legalLinks": [
                    { "label": "Privacy", "href": "/privacy" },
                    { "label": "Terms", "href": "/terms" },
                    { "label": "Disclaimer", "href": "/disclaimer" }
                ]
            }
        },
        "branding": {
            "siteName": "Volubit",
            "tagline": "Crypto education that respects your intelligence.",
            "logo": "@/assets/logo.png",
            "favicon": "@/assets/favicon.ico"
        },
        "announcementBar": {
            "enabled": false,
            "text": "",
            "href": null
        },
        "seo": {
            "defaultMetaTitle": "Volubit — Crypto Education Community",
            "defaultMetaDesc": "A premium members-only crypto education platform...",
            "defaultOgImage": "@/assets/og-default.png",
            "socialLinks": {
                "discord": "https://discord.gg/volubit",
                "telegram": "https://t.me/volubit",
                "twitter": "https://x.com/volubit",
                "instagram": "https://instagram.com/volubit",
                "linkedin": "https://linkedin.com/company/volubit",
                "youtube": "https://youtube.com/@volubit"
            }
        }
    },
    "cachedAt": "2026-08-23T10:00:00+07:00"
}
```

---

### 2.3 Landing Page

#### `GET /api/landing/{page}`

Fetch a complete landing page in a single request: nav settings, all content blocks, top 3 featured articles, and top 3 upcoming events. Cached 5 minutes.

**Path Parameters**

| Param  | Type   | Description                      |
| ------ | ------ | -------------------------------- |
| `page` | string | Page key (e.g. `home`, `events`) |

**Response `200`**

```json
{
    "success": true,
    "data": {
        "nav": {
            "items": [
                /* nav link objects */
            ]
        },
        "blocks": {
            "hero": {
                /* ContentBlockResource */
            },
            "gallery": {
                /* ContentBlockResource */
            }
        },
        "featuredArticles": [
            {
                /* ArticleResource */
            },
            {
                /* ArticleResource */
            }
        ],
        "upcomingEvents": [
            {
                /* EventResource */
            },
            {
                /* EventResource */
            }
        ]
    },
    "cachedAt": "2026-08-23T10:00:00+07:00"
}
```

> **Use case:** Single SSR request per page — the FE root layout fetches this endpoint, extracts `nav` for the Nav component and `blocks` for page sections, eliminating N+1 fetch waterfalls.

---

### 2.4 Articles

#### `GET /api/articles`

List published articles with optional filters. Cached 5 minutes.

**Query Parameters**

| Param       | Type            | Default | Description                               |
| ----------- | --------------- | ------- | ----------------------------------------- |
| `category`  | string          | —       | Filter by category slug                   |
| `featured`  | `true`          | —       | Return featured articles only             |
| `search`    | string          | —       | Full-text search on `title` and `excerpt` |
| `published` | `true`\|`false` | `true`  | Set `false` to include drafts             |
| `page`      | integer         | `1`     | Page number                               |
| `limit`     | integer         | `10`    | Items per page. Max `50`.                 |

**Response `200`**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "slug": "liquidity-leads-price",
      "title": "Why liquidity leads price, and how to track it",
      "excerpt": "<p>A deep dive into macro liquidity cycles...</p>",
      "content": "<p>...</p>",
      "category": "macro",
      "tags": ["bitcoin", "macro"],
      "author": "Rizky Pratama",
      "readTime": "8 min",
      "image": { "url": "https://...", "alt": "..." } | null,
      "isFeatured": false,
      "publishedAt": "2026-08-01T00:00:00+07:00",
      "createdAt": "2026-08-01T00:00:00+07:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "total": 25,
    "last_page": 3
  }
}
```

---

#### `GET /api/articles/featured`

Fetch the top featured article (ordered by `sort_order`).

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* ArticleResource */
    }
}
```

**Response `404`**

```json
{
    "success": false,
    "error": { "code": "NOT_FOUND", "message": "No featured article found." }
}
```

---

#### `GET /api/articles/{slug}`

Fetch a single published article by slug.

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* ArticleResource */
    }
}
```

**Response `404`**

```json
{
    "success": false,
    "error": { "code": "NOT_FOUND", "message": "Article not found." }
}
```

---

#### `GET /api/article-categories`

List article categories ordered by `sort_order`. (Alias: `/api/articles/categories`).

**Query Parameters**

| Param | Type | Default | Description |
|---|---|---|---|
| `search` | string | — | Search category by name or description |
| `sort` | string | `sort_order` | Sort field (`sort_order`, `name`, `created_at`) |
| `order` | string | `asc` | Sort direction (`asc`, `desc`) |
| `page` | integer | — | Optional page number for pagination |
| `limit` | integer | — | Optional items per page (max 50) |

**Response `200` (List without pagination params)**

```json
{
    "success": true,
    "data": [
        {
            "id": "uuid",
            "name": "Macro",
            "slug": "macro",
            "description": "Market analysis and macroeconomic trends",
            "sortOrder": 1,
            "createdAt": "2026-08-20T10:00:00+07:00",
            "updatedAt": "2026-08-20T10:00:00+07:00"
        }
    ]
}
```

**Response `200` (When `page` or `limit` is provided)**

```json
{
    "success": true,
    "data": [
        {
            "id": "uuid",
            "name": "Macro",
            "slug": "macro",
            "description": "Market analysis and macroeconomic trends",
            "sortOrder": 1,
            "createdAt": "2026-08-20T10:00:00+07:00",
            "updatedAt": "2026-08-20T10:00:00+07:00"
        }
    ],
    "meta": {
        "current_page": 1,
        "per_page": 10,
        "total": 7,
        "last_page": 1
    }
}
```

---

#### `GET /api/article-categories/{slug}`

Fetch a single article category by `slug` or `id`.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "name": "Macro",
        "slug": "macro",
        "description": "Market analysis and macroeconomic trends",
        "sortOrder": 1,
        "createdAt": "2026-08-20T10:00:00+07:00",
        "updatedAt": "2026-08-20T10:00:00+07:00"
    }
}
```

**Response `404`**

```json
{
    "success": false,
    "error": { "code": "NOT_FOUND", "message": "Category not found." }
}
```

---

### 2.5 Events

#### `GET /api/events`

List published events with optional filters. Cached 5 minutes.

**Query Parameters**

| Param      | Type                            | Default    | Description                 |
| ---------- | ------------------------------- | ---------- | --------------------------- |
| `status`   | `upcoming`\|`past`\|`cancelled` | `upcoming` | Filter by event status      |
| `featured` | `true`\|`false`                 | —          | Return featured events only |
| `page`     | integer                         | `1`        | Page number                 |
| `limit`    | integer                         | `10`       | Items per page. Max `50`.   |

**Response `200`**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "slug": "macro-outlook-q4-webinar",
      "title": "Macro Outlook Q4 · Live Webinar",
      "description": "<p>A two-hour session on liquidity...</p>",
      "shortDesc": "A two-hour session on liquidity, rates and how they shape crypto risk appetite.",
      "date": "2026-09-12",
      "time": "20:00",
      "endTime": "22:00",
      "locationType": "online",
      "place": "Zoom",
      "meetingUrl": "https://zoom.us/j/...",
      "seats": 42,
      "seatsTaken": 12,
      "isFull": false,
      "speaker": "Rizky Pratama",
      "speakerRole": "Head of Research",
      "speakerPhoto": { "url": "https://...", "alt": "Rizky Pratama" } | null,
      "agenda": [
        { "time": "20:00", "title": "Liquidity cycle analysis" },
        { "time": "20:40", "title": "Rates & risk assets" }
      ],
      "coverImage": { "url": "https://...", "alt": "Macro Outlook Q4" } | null,
      "status": "upcoming",
      "isFeatured": true,
      "recordingUrl": null,
      "createdAt": "2026-08-01T00:00:00+07:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "total": 5,
    "last_page": 1
  }
}
```

> **Status auto-derivation:** If an event's `date` has passed, the `status` field is automatically returned as `"past"` regardless of the stored value.

---

#### `GET /api/events/{slug}`

Fetch a single published event by slug.

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* EventResource */
    }
}
```

**Response `404`**

```json
{
    "success": false,
    "error": { "code": "NOT_FOUND", "message": "Event not found." }
}
```

---

### 2.6 Public Forms

#### `POST /api/newsletter`

Subscribe an email address to the newsletter. No authentication required. Rate limited at 30 req/min per IP.

**Request body**

```json
{
    "email": "user@example.com"
}
```

**Validation:** `email` (required, valid email format)

**Response `200`**

```json
{
    "success": true,
    "message": "Subscribed successfully."
}
```

**Response `422`** — Validation failure

```json
{
    "message": "The given data was invalid.",
    "errors": {
        "email": ["The email field is required."],
        "email": ["The email must be a valid email address."]
    }
}
```

**Response `429`** — Rate limit exceeded.

---

#### `POST /api/contact`

Submit a contact form message. No authentication required. Rate limited at 10 req/min per IP.

**Request body**

```json
{
    "name": "John Doe",
    "email": "john@example.com",
    "subject": "Membership inquiry",
    "whatsapp": "+62 811 0000 111",
    "message": "I'd like to know more about the yearly plan."
}
```

**Validation**

| Field      | Required | Notes              |
| ---------- | -------- | ------------------ |
| `name`     | Yes      | —                  |
| `email`    | Yes      | Valid email format |
| `subject`  | Yes      | —                  |
| `message`  | Yes      | —                  |
| `whatsapp` | No       | —                  |

**Response `200`**

```json
{
    "success": true,
    "message": "Message received. We'll reply within 1 business day."
}
```

---

#### `POST /api/partnership/inquiry`

Submit a partnership inquiry form. No authentication required. Rate limited at 10 req/min per IP.

**Request body**

```json
{
    "company": "Acme Corp",
    "contact_name": "Jane Doe",
    "email": "jane@acme.com",
    "phone": "+62 21 0000 0000",
    "message": "We're interested in a corporate training partnership."
}
```

**Validation**

| Field          | Required | Notes              |
| -------------- | -------- | ------------------ |
| `company`      | Yes      | —                  |
| `contact_name` | Yes      | —                  |
| `email`        | Yes      | Valid email format |
| `phone`        | No       | —                  |
| `message`      | No       | —                  |

**Response `200`**

```json
{
    "success": true,
    "message": "Inquiry received. We'll be in touch within 1-2 business days."
}
```

---

#### `POST /api/checkout/register`

Register a new member and initiate checkout. Creates a pending membership record. No authentication required. Rate limited at 20 req/min per IP.

**Request body**

```json
{
    "plan": "yearly",
    "name": "John Doe",
    "email": "john@example.com",
    "whatsapp": "+62 811 0000 111",
    "discord": "johndoe#1234",
    "telegram": "@johndoe",
    "coupon": "EARLYBIRD10"
}
```

**Validation**

| Field      | Required | Notes                                              |
| ---------- | -------- | -------------------------------------------------- |
| `plan`     | Yes      | One of: `monthly`, `quarterly`, `yearly`           |
| `name`     | Yes      | —                                                  |
| `email`    | Yes      | Valid email format, must be unique                 |
| `whatsapp` | Yes      | —                                                  |
| `discord`  | Yes      | Discord username (e.g. `johndoe#1234`)             |
| `telegram` | No       | Telegram username (e.g. `@johndoe`)                |
| `coupon`   | No       | Valid coupon code; discounts applied if recognized |

**Response `200`**

```json
{
    "success": true,
    "data": {
        "member_id": "VLB-2026-0001",
        "plan": "yearly",
        "amount": "Rp 2.9jt",
        "payment_url": "https://payment.gateway.example/pay/abc123",
        "expires_at": "2026-08-24T11:00:00+07:00"
    },
    "message": "Checkout initiated. Complete payment within 30 minutes."
}
```

**Response `422`** — Validation failure or duplicate email.

```json
{
    "message": "The given data was invalid.",
    "errors": {
        "email": ["The email has already been taken."]
    }
}
```

**Side effects:** Sends confirmation email with member ID; sends WhatsApp message with Discord invite link.

---

## 3. Auth API

Session-based authentication using Laravel's `auth:web` guard. Requires an active session cookie. All auth routes are CSRF-protected.

**CSRF Note:** For browser-based clients, first fetch the CSRF token from `/sanctum/csrf-cookie` before making `POST /api/auth/login`.

**Rate limit:** `auth-login` (10 req/min per IP) applies to `POST /api/auth/login` only.

---

### 3.1 `POST /api/auth/login`

Authenticate and establish a session.

**Request body**

```json
{
    "email": "admin@volubit.io",
    "password": "secret123"
}
```

**Validation:** `email` (required, email format), `password` (required, string)

**Response `200`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "name": "Admin Name",
        "email": "admin@volubit.io",
        "isActive": true,
        "lastLogin": "2026-08-22T09:00:00+07:00"
    }
}
```

**Response `401`**

```json
{
    "success": false,
    "error": {
        "code": "INVALID_CREDENTIALS",
        "message": "Invalid email or password."
    }
}
```

**Side effects:** Session regenerated, `last_login` updated on `AdminUser`, audit log entry created (action: `login`).

---

### 3.2 `POST /api/auth/refresh`

Regenerate the session ID. Requires an active session.

**Response `200`**

```json
{
    "success": true,
    "data": { "id": "uuid", "name": "...", "email": "..." }
}
```

**Response `401`** — Not authenticated.

---

### 3.3 `GET /api/auth/me`

Fetch the currently authenticated admin user.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "name": "Admin Name",
        "email": "admin@volubit.io",
        "isActive": true,
        "lastLogin": "2026-08-22T09:00:00+07:00",
        "createdAt": "2026-01-01T00:00:00+00:00"
    }
}
```

**Response `401`** — Not authenticated.

---

### 3.4 `POST /api/auth/logout`

Invalidate the current session.

**Response `200`**

```json
{
    "success": true,
    "message": "Logged out successfully."
}
```

**Side effects:** Session invalidated, CSRF token regenerated, audit log entry created (action: `logout`).

---

## 4. Admin API

**All admin routes require `X-Api-Token` header** set to the value of `CMS_ADMIN_API_TOKEN`.

```
X-Api-Token: your-api-token-here
```

**Rate limit:** `admin-api` — 300 req/min per IP.

**Response `401`** — Missing or invalid token:

```json
{
    "success": false,
    "error": {
        "code": "UNAUTHORIZED",
        "message": "Invalid or missing API token."
    }
}
```

---

### 4.1 Content Management

#### `GET /api/admin/content`

List all content blocks with optional filters.

**Query Parameters**

| Param        | Type   | Description                             |
| ------------ | ------ | --------------------------------------- |
| `page`       | string | Filter by page key (e.g. `home`)        |
| `block_type` | string | Filter by block type (e.g. `HeroBlock`) |

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* Laravel paginator, 50 per page */
    }
}
```

---

#### `GET /api/admin/content/{id}`

Fetch a single content block by UUID.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "page": "home",
        "section": "hero",
        "blockType": "HeroBlock",
        "locale": "en",
        "data": {
            /* full block data */
        },
        "updatedAt": "2026-08-20T14:30:00+07:00",
        "updatedBy": "Admin Name"
    }
}
```

**Response `404`**

```json
{
    "success": false,
    "error": { "code": "NOT_FOUND", "message": "Content block not found." }
}
```

---

#### `PUT /api/admin/content/{id}`

Apply JSON Patch-style operations to a content block. Changes are immediate — there is no draft/publish step. Triggers the `content.updated` webhook.

**Request body**

```json
{
    "operations": [
        { "op": "update", "path": "/headline", "value": "New Headline Text" },
        { "op": "update", "path": "/ctaPrimary/label", "value": "Join Now" },
        {
            "op": "addItem",
            "path": "/stats",
            "value": { "value": 1000, "suffix": "+", "label": "Members" }
        },
        { "op": "removeItem", "path": "/stats", "index": 0 },
        {
            "op": "replace",
            "path": "",
            "value": { "eyebrow": "...", "headline": "..." }
        }
    ]
}
```

**Operation reference**

| Op           | Description                                             | Required Fields            |
| ------------ | ------------------------------------------------------- | -------------------------- |
| `update`     | Update a specific field (dot-notation for nested paths) | `path`, `value`            |
| `addItem`    | Append an item to an array field                        | `path`, `value`            |
| `removeItem` | Remove an item from an array field by index             | `path`, `index`            |
| `replace`    | Replace the entire block data                           | `path` (use `""`), `value` |

> **Path notation:** Use dot notation for nested fields (e.g. `/ctaPrimary/label`). For array fields, the path is the field name (e.g. `/stats`, `/items`). Use `index` to remove at a specific position.

**Concrete examples**

```json
// Update hero headline
{ "op": "update", "path": "/headline", "value": "New headline text" }

// Update a stat value
{ "op": "update", "path": "/stats/0/value", "value": 6000 }

// Update a nested CTA label
{ "op": "update", "path": "/ctaPrimary/label", "value": "Join Now" }

// Add a new mentor card to MentorBlock
{ "op": "addItem", "path": "/items", "value": { "name": "New Mentor", "role": "Analyst" } }

// Remove the first gallery image
{ "op": "removeItem", "path": "/items", "index": 0 }

// Replace entire block data
{ "op": "replace", "path": "", "value": { "eyebrow": "...", "headline": "..." } }
```

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* updated ContentBlockResource */
    },
    "message": "Content block updated successfully."
}
```

**Side effects:** Audit log entry created (action: `update`), `content.updated` webhook dispatched.

---

### 4.2 Article Management

#### `GET /api/admin/articles`

List all articles (published and unpublished).

**Query Parameters**

| Param       | Type            | Default | Description              |
| ----------- | --------------- | ------- | ------------------------ |
| `category`  | string          | —       | Filter by category slug  |
| `published` | `true`\|`false` | —       | Filter by publish status |
| `page`      | integer         | `1`     | Page number              |
| `limit`     | integer         | `20`    | Items per page           |

**Response `200`** — Paginated `AdminArticleResource` collection with `updater` relation.

---

#### `GET /api/admin/articles/{id}`

Fetch a single article by UUID.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "slug": "article-slug",
        "title": "Article Title",
        "excerpt": "<p>Short summary for cards and listings.</p>",
        "content": "<p>Full article content in HTML.</p>",
        "category": "macro",
        "tags": ["bitcoin", "macro"],
        "author": "Rizky Pratama",
        "readTime": "8 min",
        "imageUrl": "https://...",
        "imageAlt": "Alt text",
        "isFeatured": false,
        "isPublished": true,
        "sortOrder": 0,
        "publishedAt": "2026-08-01T00:00:00+07:00",
        "createdAt": "2026-07-15T00:00:00+07:00",
        "updatedAt": "2026-08-01T00:00:00+07:00",
        "updatedBy": "Admin Name"
    }
}
```

**Response `404`** — Article not found.

---

#### `POST /api/admin/articles`

Create a new article. Created as draft (`is_published = false`) by default. Triggers the `article.created` webhook.

**Request body**

```json
{
    "title": "Article Title",
    "slug": "custom-slug",
    "excerpt": "<p>Short summary for cards and listings.</p>",
    "content": "<p>Full article content in HTML.</p>",
    "category": "macro",
    "tags": ["bitcoin", "trading"],
    "author": "Rizky Pratama",
    "read_time": "8 min",
    "image_url": "https://media.volubit.io/cover.jpg",
    "image_alt": "Article cover image",
    "is_featured": false,
    "is_published": false,
    "sort_order": 0
}
```

**Validation**

| Field          | Required | Notes                                                                       |
| -------------- | -------- | --------------------------------------------------------------------------- |
| `title`        | Yes      | —                                                                           |
| `excerpt`      | Yes      | HTML allowed, sanitized on save                                             |
| `content`      | Yes      | HTML allowed, sanitized on save                                             |
| `category`     | Yes      | Category slug string                                                        |
| `author`       | Yes      | —                                                                           |
| `read_time`    | Yes      | e.g. `"8 min"`                                                              |
| `slug`         | No       | Auto-generated from title if omitted. Duplicates auto-suffixed (`title-2`). |
| `tags`         | No       | JSON array, defaults to `[]`                                                |
| `image_url`    | No       | Full URL to image                                                           |
| `image_alt`    | No       | Alt text for image                                                          |
| `is_featured`  | No       | Defaults to `false`                                                         |
| `is_published` | No       | Defaults to `false` (draft)                                                 |
| `sort_order`   | No       | Defaults to `0`                                                             |

**Response `201`**

```json
{
    "success": true,
    "data": {
        /* AdminArticleResource */
    },
    "message": "Article created successfully."
}
```

**Side effects:** If `is_published` is `true`, `published_at` is set automatically. Audit log entry created (action: `create`). `article.created` webhook dispatched.

---

#### `PUT /api/admin/articles/{id}`

Update an article. All fields are optional. Triggers the `article.updated` webhook.

**Request body** — Same shape as `POST /api/admin/articles`. All fields except those marked required above are optional.

**Special behavior:** On first publish (transitioning `is_published` from `false` to `true`), `published_at` is automatically set to the current timestamp.

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* AdminArticleResource */
    },
    "message": "Article updated successfully."
}
```

**Side effects:** Audit log entry created (action: `update`). `article.updated` webhook dispatched.

---

#### `DELETE /api/admin/articles/{id}`

Delete an article permanently. Triggers the `article.deleted` webhook.

**Response `200`**

```json
{
    "success": true,
    "message": "Article deleted successfully."
}
```

**Response `404`** — Article not found.

---

### 4.3 Article Categories

#### `GET /api/admin/article-categories`

List all categories ordered by `sort_order`.

**Response `200`**

```json
{
    "success": true,
    "data": [
        {
            "id": "uuid",
            "name": "Macro",
            "slug": "macro",
            "description": "Market analysis and macro research",
            "sortOrder": 0
        }
    ]
}
```

---

#### `POST /api/admin/article-categories`

Create a new category.

**Request body**

```json
{
    "name": "Category Name",
    "slug": "category-slug",
    "description": "Optional description",
    "sort_order": 0
}
```

| Field         | Required | Notes                                                          |
| ------------- | -------- | -------------------------------------------------------------- |
| `name`        | Yes      | —                                                              |
| `slug`        | No       | Auto-generated from name if omitted. Duplicates auto-suffixed. |
| `description` | No       | —                                                              |
| `sort_order`  | No       | Defaults to `0`                                                |

**Response `201`**

```json
{
    "success": true,
    "data": { "id": "uuid", "name": "...", "slug": "...", "sortOrder": 0 },
    "message": "Category created successfully."
}
```

**Side effects:** Audit log entry created (action: `create`).

---

#### `PUT /api/admin/article-categories/{id}`

Update a category. All fields are optional.

**Request body** — Same shape as `POST`.

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* updated category */
    },
    "message": "Category updated successfully."
}
```

**Side effects:** Audit log entry created (action: `update`).

---

#### `DELETE /api/admin/article-categories/{id}`

Delete a category. Does not reassign articles — articles retain the old category string.

**Response `200`**

```json
{
    "success": true,
    "message": "Category deleted successfully."
}
```

**Response `404`** — Category not found.

**Side effects:** Audit log entry created (action: `delete`).

---

### 4.4 Event Management

#### `GET /api/admin/events`

List all events (all statuses).

**Query Parameters**

| Param       | Type                                                | Default | Description              |
| ----------- | --------------------------------------------------- | ------- | ------------------------ |
| `status`    | `draft`\|`upcoming`\|`ongoing`\|`past`\|`cancelled` | —       | Filter by status         |
| `published` | `true`\|`false`                                     | —       | Filter by publish status |
| `page`      | integer                                             | `1`     | Page number              |
| `limit`     | integer                                             | `20`    | Items per page           |

**Response `200`** — Paginated `AdminEventResource` collection with `updater` relation.

---

#### `GET /api/admin/events/{id}`

Fetch a single event by UUID.

**Response `200`** — `AdminEventResource`

**Response `404`** — Event not found.

---

#### `POST /api/admin/events`

Create a new event. Triggers the `event.created` webhook.

**Request body**

```json
{
    "title": "Event Title",
    "slug": "custom-slug",
    "description": "<p>Full event description in HTML.</p>",
    "short_desc": "One-line summary for cards.",
    "date": "2026-09-12",
    "time": "20:00",
    "end_time": "22:00",
    "location_type": "online",
    "place": "Zoom",
    "meeting_url": "https://zoom.us/j/...",
    "seats": 42,
    "speaker": "Rizky Pratama",
    "speaker_role": "Head of Research",
    "speaker_photo_url": "https://...",
    "speaker_photo_alt": "Speaker photo",
    "agenda": [
        { "time": "20:00", "title": "Opening remarks" },
        { "time": "20:15", "title": "Liquidity analysis" }
    ],
    "cover_image_url": "https://...",
    "cover_image_alt": "Event cover image",
    "status": "upcoming",
    "is_featured": true,
    "is_published": false,
    "sort_order": 0,
    "recording_url": null
}
```

**Validation**

| Field               | Required | Notes                                 |
| ------------------- | -------- | ------------------------------------- |
| `title`             | Yes      | —                                     |
| `description`       | Yes      | HTML allowed                          |
| `short_desc`        | Yes      | Plain text, used in cards             |
| `date`              | Yes      | ISO date format (`YYYY-MM-DD`)        |
| `time`              | Yes      | e.g. `"20:00"`                        |
| `location_type`     | Yes      | One of: `online`, `offline`, `hybrid` |
| `place`             | Yes      | Venue or platform name                |
| `speaker`           | Yes      | Speaker name                          |
| `slug`              | No       | Auto-generated and deduplicated       |
| `end_time`          | No       | —                                     |
| `meeting_url`       | No       | Video call link                       |
| `seats`             | No       | Defaults to `0`                       |
| `speaker_role`      | No       | —                                     |
| `speaker_photo_url` | No       | —                                     |
| `speaker_photo_alt` | No       | —                                     |
| `agenda`            | No       | JSON array of `{time, title}` objects |
| `cover_image_url`   | No       | —                                     |
| `cover_image_alt`   | No       | —                                     |
| `status`            | No       | Defaults to `draft`                   |
| `is_featured`       | No       | Defaults to `false`                   |
| `is_published`      | No       | Defaults to `false`                   |
| `sort_order`        | No       | Defaults to `0`                       |
| `recording_url`     | No       | Set after event occurs                |

**Response `201`**

```json
{
    "success": true,
    "data": {
        /* AdminEventResource */
    },
    "message": "Event created successfully."
}
```

**Side effects:** Audit log entry created (action: `create`). `event.created` webhook dispatched.

---

#### `PUT /api/admin/events/{id}`

Update an event. All fields optional. Triggers the `event.updated` webhook.

**Request body** — Same shape as `POST`. All fields except those marked required are optional.

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* AdminEventResource */
    },
    "message": "Event updated successfully."
}
```

**Side effects:** Audit log entry created (action: `update`). `event.updated` webhook dispatched.

---

#### `DELETE /api/admin/events/{id}`

Delete an event permanently. Triggers the `event.deleted` webhook.

**Response `200`**

```json
{
    "success": true,
    "message": "Event deleted successfully."
}
```

**Response `404`** — Event not found.

---

### 4.5 Media Management

#### `GET /api/admin/media`

List media files with pagination and search.

**Query Parameters**

| Param       | Type    | Default | Description                             |
| ----------- | ------- | ------- | --------------------------------------- |
| `q`         | string  | —       | Search by `original_name`               |
| `mime_type` | string  | —       | Filter by MIME type (e.g. `image/jpeg`) |
| `page`      | integer | `1`     | Page number                             |
| `limit`     | integer | `20`    | Items per page                          |

**Response `200`**

```json
{
    "success": true,
    "data": [
        {
            "id": "uuid",
            "filename": "550e8400-e29b.jpg",
            "original_name": "hero-banner.jpg",
            "url": "https://api.volubit.io/storage/media/2026/08/550e8400-e29b.jpg",
            "resized_url": "https://api.volubit.io/storage/media/2026/08/550e8400-e29b-resized.jpg",
            "thumbnail_url": "https://api.volubit.io/storage/media/2026/08/550e8400-e29b-thumb.jpg",
            "mime_type": "image/jpeg",
            "size": 204800,
            "width": 1920,
            "height": 1080,
            "alt": "Hero banner",
            "uploaded_by": "Admin Name"
        }
    ],
    "meta": { "current_page": 1, "per_page": 20, "total": 50, "last_page": 3 }
}
```

---

#### `POST /api/admin/media/upload`

Upload a file. Creates three variants: original, resized (max 1920px wide), thumbnail (400px wide). Triggers an audit log entry (action: `upload`).

**Request:** `multipart/form-data`

| Field  | Required | Notes                  |
| ------ | -------- | ---------------------- |
| `file` | Yes      | Binary file data       |
| `alt`  | No       | Alt text for the image |

**Allowed file types:** `jpg`, `jpeg`, `png`, `webp`, `gif`, `svg`, `pdf`
**Max file size:** 10MB

**Response `201`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "filename": "550e8400-e29b.jpg",
        "original_name": "hero-banner.jpg",
        "url": "https://api.volubit.io/storage/media/2026/08/550e8400-e29b.jpg",
        "resized_url": "https://api.volubit.io/storage/media/2026/08/550e8400-e29b-resized.jpg",
        "thumbnail_url": "https://api.volubit.io/storage/media/2026/08/550e8400-e29b-thumb.jpg",
        "mime_type": "image/jpeg",
        "size": 204800,
        "width": 1920,
        "height": 1080,
        "alt": "Hero banner",
        "uploaded_by": "uuid"
    },
    "message": "File uploaded successfully."
}
```

**Response `422`** — Validation failure (wrong file type, file too large).
**Response `500`** — Storage write failure.

---

#### `DELETE /api/admin/media/{id}`

Delete a media file and all its variants from storage.

**Response `200`**

```json
{
    "success": true,
    "message": "Media file deleted successfully."
}
```

**Response `404`** — Media not found.

**Response `409 Conflict`** — File is currently referenced by at least one `ContentBlock`, `Article`, or `Event` record.

```json
{
    "success": false,
    "error": {
        "code": "IN_USE",
        "message": "Image is in use and cannot be deleted."
    }
}
```

To delete, first remove the image reference from all content that uses it.

---

### 4.6 Admin User Management

#### `GET /api/admin/admins`

List all admin accounts.

**Response `200`**

```json
{
    "success": true,
    "data": [
        {
            "id": "uuid",
            "name": "Admin Name",
            "email": "admin@volubit.io",
            "isActive": true,
            "lastLogin": "2026-08-22T09:00:00+07:00",
            "createdAt": "2026-01-01T00:00:00+00:00"
        }
    ]
}
```

---

#### `POST /api/admin/admins`

Create a new admin account. Requires an active session (from Auth API).

**Request body**

```json
{
    "name": "New Admin",
    "email": "newadmin@volubit.io",
    "password": "min8characters"
}
```

| Field      | Required | Notes                            |
| ---------- | -------- | -------------------------------- |
| `name`     | Yes      | —                                |
| `email`    | Yes      | Must be unique across all admins |
| `password` | Yes      | Minimum 8 characters             |

**Response `201`**

```json
{
    "success": true,
    "data": {
        "id": "uuid",
        "name": "New Admin",
        "email": "newadmin@volubit.io",
        "isActive": true,
        "createdAt": "2026-08-23T00:00:00+07:00"
    },
    "message": "Admin user created successfully."
}
```

**Side effects:** Audit log entry created (action: `create`).

---

#### `PUT /api/admin/admins/{id}`

Update an admin account.

**Request body**

```json
{
    "name": "Updated Name",
    "email": "updated@volubit.io",
    "password": "newpassword",
    "is_active": true
}
```

| Field       | Required | Notes                                           |
| ----------- | -------- | ----------------------------------------------- |
| `name`      | No       | —                                               |
| `email`     | No       | Must remain unique                              |
| `password`  | No       | Minimum 8 characters. Only updated if provided. |
| `is_active` | No       | `true` activates, `false` deactivates           |

**Response `200`**

```json
{
    "success": true,
    "data": {
        /* updated admin */
    },
    "message": "Admin user updated successfully."
}
```

**Side effects:** Audit log entry created (action: `update`).

---

#### `DELETE /api/admin/admins/{id}`

Deactivate (soft-delete) an admin account. Cannot delete your own account.

**Response `200`**

```json
{
    "success": true,
    "message": "Admin user deleted successfully."
}
```

**Response `404`** — Admin not found.

**Response `422`** — Attempted self-deletion.

```json
{
    "success": false,
    "error": {
        "code": "CANNOT_DELETE_SELF",
        "message": "You cannot delete your own account."
    }
}
```

**Side effects:** Audit log entry created (action: `delete`).

---

### 4.7 Dashboard Stats

#### `GET /api/admin/stats`

Fetch dashboard statistics.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "totalBlocks": 25,
        "totalMedia": 120,
        "totalAdmins": 5,
        "activeAdmins": 4,
        "recentEdits": 87,
        "blocksPerPage": {
            "home": 8,
            "community": 7,
            "about": 4,
            "events": 5
        }
    }
}
```

---

### 4.8 Audit Log

#### `GET /api/admin/audit-log`

Browse the audit log with filters. Returns 50 entries per page, newest first.

**Query Parameters**

| Param          | Type    | Description                                                                 |
| -------------- | ------- | --------------------------------------------------------------------------- |
| `action`       | string  | Filter by action: `create`, `update`, `delete`, `login`, `logout`, `upload` |
| `admin_id`     | string  | Filter by admin UUID                                                        |
| `content_page` | string  | Filter by affected content page (e.g. `home`, `events`)                     |
| `from`         | `Y-m-d` | Start date (e.g. `2026-08-01`)                                              |
| `to`           | `Y-m-d` | End date (e.g. `2026-08-23`)                                                |
| `page`         | integer | Page number (default `1`)                                                   |

**Response `200`**

```json
{
    "success": true,
    "data": {
        "data": [
            {
                "id": "uuid",
                "admin_id": "uuid",
                "action": "update",
                "block_id": "uuid",
                "block_page": "home",
                "block_section": "hero",
                "metadata": { "path": "headline", "old": "...", "new": "..." },
                "ip_address": "127.0.0.1",
                "created_at": "2026-08-23T10:30:00+07:00",
                "admin": {
                    "id": "uuid",
                    "name": "Admin Name",
                    "email": "admin@volubit.io"
                }
            }
        ],
        "current_page": 1,
        "per_page": 50,
        "total": 200,
        "last_page": 4
    }
}
```

---

### 4.9 Site Settings (Admin)

#### `GET /api/admin/site-settings`

Fetch all site settings. Identical to the public `GET /api/site-settings` but bypasses cache.

**Response `200`**

```json
{
    "success": true,
    "data": {
        "nav": {
            "items": [ /* ... */ ]
        },
        "footer": { /* ... */ },
        "branding": {
            /* ... */
        },
        "announcementBar": {
            /* ... */
        },
        "seo": {
            /* ... */
        }
    }
}
```

---

#### `PUT /api/admin/site-settings`

Update a site setting by key. Triggers the `site_settings.updated` webhook.

**Request body**

```json
{
    "key": "announcementBar",
    "value": {
        "enabled": true,
        "text": "Join our next class — Thursday 20:00 WIB",
        "href": "/events"
    }
}
```

**Valid `key` values:** `nav`, `footer`, `branding`, `announcementBar`, `seo`

**Response `200`**

```json
{
    "success": true,
    "message": "Site setting updated successfully."
}
```

**Behavior:** Creates the setting if the key does not exist; updates it if it does.

**Side effects:** Audit log entry created (action: `update`). `site_settings.updated` webhook dispatched.

---

## 5. Data Models

### 5.1 Article

| Field          | DB Column      | Type              | Notes                                  |
| -------------- | -------------- | ----------------- | -------------------------------------- |
| `id`           | `id`           | UUID              | Primary key                            |
| `slug`         | `slug`         | string            | Unique URL slug                        |
| `title`        | `title`        | string            | Required                               |
| `excerpt`      | `excerpt`      | HTML string       | Used in cards/listings, HTML sanitized |
| `content`      | `content`      | HTML string       | Full article body, HTML sanitized      |
| `category`     | `category`     | string            | Category slug                          |
| `tags`         | `tags`         | JSON array        | e.g. `["bitcoin","macro"]`             |
| `author`       | `author`       | string            | Author display name                    |
| `read_time`    | `read_time`    | string            | e.g. `"8 min"`                         |
| `image_url`    | `image_url`    | string nullable   | Main article cover image URL           |
| `image_alt`    | `image_alt`    | string nullable   |                                        |
| `is_featured`  | `is_featured`  | boolean           |                                        |
| `is_published` | `is_published` | boolean           | Defaults to `false` on create          |
| `sort_order`   | `sort_order`   | integer           |                                        |
| `published_at` | `published_at` | datetime nullable | Auto-set on first publish              |
| `updated_by`   | `updated_by`   | UUID nullable     | FK to `admin_users`                    |
| `created_at`   | `created_at`   | datetime          |                                        |
| `updated_at`   | `updated_at`   | datetime          |                                        |

### 5.2 Event

| Field               | DB Column           | Type            | Notes                                               |
| ------------------- | ------------------- | --------------- | --------------------------------------------------- |
| `id`                | `id`                | UUID            | Primary key                                         |
| `slug`              | `slug`              | string          | Unique                                              |
| `title`             | `title`             | string          |                                                     |
| `description`       | `description`       | HTML string     | Full event description                              |
| `short_desc`        | `short_desc`        | string          | For card/list view                                  |
| `date`              | `date`              | date            | ISO format `YYYY-MM-DD`                             |
| `time`              | `time`              | string          | e.g. `"20:00"`                                      |
| `end_time`          | `end_time`          | string nullable |                                                     |
| `location_type`     | `location_type`     | enum            | `online`, `offline`, `hybrid`                       |
| `place`             | `place`             | string          | Venue or platform name                              |
| `meeting_url`       | `meeting_url`       | string nullable | Video call link                                     |
| `seats`             | `seats`             | integer         | Total capacity                                      |
| `seats_taken`       | `seats_taken`       | integer         |                                                     |
| `is_full`           | `is_full`           | boolean         | Computed; can be manually overridden                |
| `speaker`           | `speaker`           | string          |                                                     |
| `speaker_role`      | `speaker_role`      | string nullable |                                                     |
| `speaker_photo_url` | `speaker_photo_url` | string nullable |                                                     |
| `speaker_photo_alt` | `speaker_photo_alt` | string nullable |                                                     |
| `agenda`            | `agenda`            | JSON array      | `[{time, title}]`                                   |
| `cover_image_url`   | `cover_image_url`   | string nullable |                                                     |
| `cover_image_alt`   | `cover_image_alt`   | string nullable |                                                     |
| `status`            | `status`            | enum            | `draft`, `upcoming`, `ongoing`, `past`, `cancelled` |
| `is_featured`       | `is_featured`       | boolean         |                                                     |
| `is_published`      | `is_published`      | boolean         |                                                     |
| `sort_order`        | `sort_order`        | integer         |                                                     |
| `recording_url`     | `recording_url`     | string nullable | Set after event occurs                              |
| `updated_by`        | `updated_by`        | UUID nullable   |                                                     |
| `created_at`        | `created_at`        | datetime        |                                                     |
| `updated_at`        | `updated_at`        | datetime        |                                                     |

### 5.3 ArticleCategory

| Field         | DB Column     | Type            |
| ------------- | ------------- | --------------- |
| `id`          | `id`          | UUID            |
| `name`        | `name`        | string          |
| `slug`        | `slug`        | string (unique) |
| `description` | `description` | string nullable |
| `sort_order`  | `sort_order`  | integer         |
| `created_at`  | `created_at`  | datetime        |
| `updated_at`  | `updated_at`  | datetime        |

### 5.4 SiteSetting

| Field       | DB Column | Type            |
| ----------- | --------- | --------------- |
| `id`        | `id`      | UUID            |
| `key`       | `key`     | string (unique) |
| `value`     | `value`   | JSON           |
| `updatedAt` | `updated_at` | datetime     |

**Resource:** `SiteSettingsResource`

Valid `key` values: `nav`, `footer`, `branding`, `announcementBar`, `seo`

### 5.5 ContentBlock

| Field        | DB Column    | Type                   |
| ------------ | ------------ | ---------------------- |
| `id`         | `id`         | UUID                   |
| `page`       | `page`       | string (e.g. `home`)   |
| `section`    | `section`    | string (e.g. `hero`)   |
| `block_type` | `block_type` | BlockType enum string  |
| `locale`     | `locale`     | string (always `"en"`) |
| `data`       | `data`       | JSON object            |
| `updated_by` | `updated_by` | UUID nullable          |
| `created_at` | `created_at` | datetime               |
| `updated_at` | `updated_at` | datetime               |

### 5.6 Media

| Field           | DB Column       | Type                               |
| --------------- | --------------- | ---------------------------------- |
| `id`            | `id`            | UUID                               |
| `filename`      | `filename`      | string (stored name with UUID)     |
| `original_name` | `original_name` | string (user-uploaded name)        |
| `url`           | `url`           | string (public URL, original size) |
| `resized_url`   | `resized_url`   | string nullable                    |
| `thumbnail_url` | `thumbnail_url` | string nullable                    |
| `mime_type`     | `mime_type`     | string                             |
| `size`          | `size`          | integer (bytes)                    |
| `width`         | `width`         | integer nullable                   |
| `height`        | `height`        | integer nullable                   |
| `alt`           | `alt`           | string nullable                    |
| `uploaded_by`   | `uploaded_by`   | UUID nullable                      |
| `created_at`    | `created_at`    | datetime                           |

### 5.7 AuditLog

| Field           | DB Column       | Type            |
| --------------- | --------------- | --------------- |
| `id`            | `id`            | UUID            |
| `admin_id`      | `admin_id`      | UUID nullable   |
| `action`        | `action`        | string          |
| `block_id`      | `block_id`      | UUID nullable   |
| `block_page`    | `block_page`    | string nullable |
| `block_section` | `block_section` | string nullable |
| `metadata`      | `metadata`      | JSON object     |
| `ip_address`    | `ip_address`    | string nullable |
| `created_at`    | `created_at`    | datetime        |

Valid `action` values: `create`, `update`, `delete`, `login`, `logout`, `upload`

### 5.8 AdminUser

| Field        | DB Column    | Type              |
| ------------ | ------------ | ----------------- |
| `id`         | `id`         | UUID              |
| `name`       | `name`       | string            |
| `email`      | `email`      | string (unique)   |
| `password`   | `password`   | hashed string     |
| `is_active`  | `is_active`  | boolean           |
| `last_login` | `last_login` | datetime nullable |
| `created_at` | `created_at` | datetime          |
| `updated_at` | `updated_at` | datetime          |

---

## 6. Block Types & Pages

### 6.1 Valid Page Keys

| Page Key            | Label             | Sections (block count) |
| ------------------- | ----------------- | ---------------------- |
| `home`              | Home              | 12 sections            |
| `community`         | Community         | 10 sections            |
| `about`             | About             | 8 sections             |
| `events`            | Events            | 8 sections             |
| `articles`          | Articles          | 2 sections             |
| `faq`               | FAQ               | 2 sections             |
| `membership`        | Membership        | 3 sections             |
| `membership-detail` | Membership Detail | 8 sections             |
| `checkout`          | Checkout          | 1 section              |
| `partnership`       | Partnership       | 5 sections             |
| `contact`           | Contact           | 4 sections             |

> **Note:** `membership-detail` is the full membership page with research dashboard and course tracks. `membership` is the membership landing/hero page.

### 6.2 Block Type Data Shapes

Every content block's `data` field conforms to one of these shapes. `image` fields store a plain URL string (not a MediaBlock object) — the Filament admin form uses URL inputs, not file upload fields.

#### `HeroBlock` (e.g. `home.hero`, `community.hero`, `about.hero`)

Common fields across all pages:

```json
{
    "eyebrow": "string",
    "headline": "string",
    "description": "string",
    "ctaPrimary": { "label": "string", "href": "string" },
    "ctaSecondary": { "label": "string", "href": "string" }
}
```

**Home hero only — adds `stats` and `floatingCards`:**

```json
{
    "eyebrow": "string",
    "headline": "string",
    "description": "string",
    "ctaPrimary": { "label": "string", "href": "string" },
    "ctaSecondary": { "label": "string", "href": "string" },
    "stats": [
        { "value": 5200, "suffix": "+", "label": "Members" },
        { "value": 180, "suffix": "+", "label": "Research notes" }
    ],
    "floatingCards": [
        {
            "type": "discord",
            "channel": "#market-talk",
            "message": "...",
            "reply": "..."
        },
        {
            "type": "portfolio",
            "title": "Members' allocation study, Q3",
            "change": "+4.2%"
        },
        {
            "type": "research",
            "title": "Macro liquidity and the Bitcoin cycle",
            "meta": "18 pages · published today"
        },
        { "type": "voice", "label": "Voice · Weekly Class", "participants": 5 }
    ]
}
```

#### `StatsBlock` (e.g. `community.stats`)

```json
{
    "items": [{ "value": 5200, "suffix": "+", "label": "Members" }]
}
```

#### `CardGridBlock` (e.g. `home.why-volubit`, `community.activities`, `partnership.process`)

Item fields vary by section:

**With icon and benefit (e.g. `home.why-volubit`):**

```json
{
    "items": [
        {
            "icon": "LineChart",
            "title": "Professional research",
            "description": "Weekly market reports written by analysts, not influencers.",
            "benefit": "Macro, on-chain and asset notes every week"
        }
    ]
}
```

**Plain cards (e.g. `community.activities`, `about.culture`, `partnership.benefits`):**

```json
{
    "items": [
        {
            "title": "Voice rooms",
            "description": "Daily open rooms for questions and chart walkthroughs."
        }
    ]
}
```

**Numbered process cards (e.g. `partnership.process`):**

```json
{
    "items": [
        {
            "number": "01",
            "title": "Introduction",
            "description": "A 30-minute call to understand your goals."
        }
    ]
}
```

#### `StatsBlock` (e.g. `community.stats`)

```json
{
    "items": [
        { "value": 5200, "suffix": "+", "label": "Members" },
        { "value": 42, "suffix": "", "label": "Study groups" },
        { "value": 1420, "suffix": "", "label": "Messages today" },
        { "value": 18, "suffix": "", "label": "Cities" }
    ]
}
```

#### `ScheduleBlock` (used by `community.schedule`, `about.timeline`, `events.calendar`)

The `items` field has a different shape per page:

**Community schedule — `{day, activity}`:**

```json
{
    "items": [
        { "day": "Monday", "activity": "Research drop + weekly outlook" },
        { "day": "Tuesday", "activity": "Beginner study group" }
    ]
}
```

**About timeline — `{year, event}`:**

```json
{
    "items": [
        {
            "year": "2021",
            "event": "A Discord server with 40 friends and one weekly call."
        },
        {
            "year": "2022",
            "event": "First paid cohort. The research note becomes weekly."
        }
    ]
}
```

**Events calendar — `{date, event, location}`:**

```json
{
    "items": [
        { "date": "12 Sep", "event": "Macro Outlook Q4", "location": "Online" },
        {
            "date": "21 Sep",
            "event": "On-chain Workshop",
            "location": "Jakarta"
        }
    ]
}
```

#### `FAQBlock` (e.g. `community.faq`, `contact.faq`)

```json
{
    "items": [
        {
            "question": "Is the Discord active?",
            "answer": "Around 1,400 messages a day across 20+ rooms."
        }
    ]
}
```

#### `FAQGroupBlock` (e.g. `faq.faq-groups`)

```json
{
    "groups": [
        {
            "label": "Membership",
            "items": [
                {
                    "question": "What is included?",
                    "answer": "Courses, weekly research, live classes, Discord and events."
                },
                {
                    "question": "Do I need experience?",
                    "answer": "No. The first track starts from zero."
                }
            ]
        }
    ]
}
```

#### `TestimonialBlock` (e.g. `home.testimonials`)

```json
{
    "items": [
        {
            "quote": "I stopped trading on vibes...",
            "name": "Bagas P.",
            "occupation": "Product Manager"
        }
    ]
}
```

#### `EventBlock` (e.g. `home.upcoming-events`, `events.upcoming-events`, `events.featured-event`)

**Upcoming events list (e.g. `home.upcoming-events`, `events.upcoming-events`):**

```json
{
    "items": [
        {
            "title": "Macro Outlook Q4 · Live Webinar",
            "speaker": "Rizky Pratama",
            "seats": 42,
            "place": "Online · Zoom",
            "date": "12 Sep, 20:00 WIB",
            "agenda": [
                "Liquidity cycle",
                "Rates & risk assets",
                "Positioning Q&A"
            ]
        }
    ]
}
```

**Featured event (e.g. `events.featured-event`) — includes `description`, `time`, and a full image object:**

```json
{
    "items": [
        {
            "title": "Macro Outlook Q4 Live Webinar",
            "description": "A two-hour session on liquidity, rates and how they shape crypto risk appetite...",
            "speaker": "Rizky Pratama",
            "seats": 42,
            "place": "Online · Zoom",
            "date": "12 Sep",
            "time": "20:00 WIB",
            "agenda": [
                "Liquidity cycle",
                "Rates & risk assets",
                "Positioning Q&A"
            ],
            "image": {
                "url": "@/assets/community-1.jpg",
                "alt": "Macro Outlook Q4 Live Webinar",
                "width": 1200,
                "height": 800,
                "mimeType": "image/jpeg",
                "size": 0
            }
        }
    ]
}
```

#### `ArticleBlock` (e.g. `home.latest-articles`)

```json
{
    "categories": ["All", "Macro", "On-chain"],
    "featured": {
        "title": "Why liquidity leads price, and how to track it",
        "author": "Rizky",
        "readTime": "8 min",
        "image": "@/assets/article-cover.jpg"
    },
    "items": [
        {
            "title": "Why liquidity leads price",
            "category": "Macro",
            "readTime": "8 min",
            "author": "Rizky",
            "image": "string (URL) | null"
        }
    ]
}
```

#### `MentorBlock` (e.g. `home.mentors`)

```json
{
    "items": [
        {
            "name": "Rizky Pratama",
            "role": "Head of Research",
            "experience": "9 yrs · digital asset research",
            "specialization": "Macro & on-chain",
            "courses": ["Macro", "On-chain Analysis"],
            "certifications": ["CFA Level II", "CMT I"],
            "photo": "@/assets/mentor-1.jpg | null"
        }
    ]
}
```

#### `ResearchBlock` (e.g. `home.research-preview`)

```json
{
    "items": [
        {
            "title": "Macro Liquidity Monitor",
            "tag": "Macro",
            "locked": false,
            "preview": "string | null"
        }
    ]
}
```

#### `GalleryBlock` (e.g. `home.gallery`)

```json
{
    "items": [
        {
            "src": "@/assets/community-1.jpg",
            "alt": "Volubit seminar in Jakarta",
            "width": 1200,
            "height": 800,
            "layoutHint": "portrait | landscape | square | null"
        }
    ]
}
```

#### `PricingBlock` (e.g. `membership.plans`)

```json
{
    "plans": [
        {
            "name": "Monthly",
            "price": "Rp 349k",
            "period": "/month",
            "note": "Cancel any time",
            "featured": false
        },
        {
            "name": "Yearly",
            "price": "Rp 2.9jt",
            "period": "/year",
            "note": "Save 31% · most popular",
            "featured": true
        }
    ],
    "matrix": [
        {
            "feature": "Discord community",
            "monthly": true,
            "quarterly": true,
            "yearly": true
        },
        {
            "feature": "Weekly live class",
            "monthly": true,
            "quarterly": true,
            "yearly": true
        },
        {
            "feature": "Course library",
            "monthly": true,
            "quarterly": true,
            "yearly": true
        },
        {
            "feature": "Research archive",
            "monthly": false,
            "quarterly": true,
            "yearly": true
        },
        {
            "feature": "1:1 mentor session",
            "monthly": false,
            "quarterly": false,
            "yearly": true
        }
    ]
}
```

#### `CTABlock` (e.g. `home.join-cta`, `home.collab-cta`)

Note: CTABlock has two field name variants depending on the section:

**Variant A — flat fields (e.g. `home.join-cta`, `community.join-cta`, `about.join-cta`):**

```json
{
    "title": "Join 5,200+ people learning crypto properly.",
    "subtitle": "One membership. Research, classes, mentors and the community.",
    "ctaLabel": "Become a member",
    "ctaHref": "/checkout",
    "ctaSecondaryLabel": "See pricing",
    "ctaSecondaryHref": "/membership/pricing"
}
```

**Variant B — nested CTA objects (e.g. `home.collab-cta`):**

```json
{
    "title": "Build something with an audience that actually shows up.",
    "description": "We work with corporates, universities, media and communities...",
    "ctaPrimary": { "label": "Partner with Volubit", "href": "/partnership" },
    "ctaSecondary": { "label": "Talk to us", "href": "/contact" },
    "chips": ["Corporate Partner", "University Partner", "Media Partner"]
}
```

#### `RichTextBlock` (e.g. `about.vision`, `contact.office`)

```json
{
    "content": "<p>A generation of investors who understand risk before they understand returns.</p>"
}
```

#### `ContactBlock` (e.g. `contact.channels`)

```json
{
    "items": [
        {
            "icon": "MessageCircle",
            "channelName": "WhatsApp",
            "value": "+62 811 0000 111",
            "note": "Fastest response"
        }
    ]
}
```

#### `PartnerBlock` (e.g. `home.partnership`, `partnership.partners`)

```json
{
    "groups": [
        {
            "groupLabel": "Strategic",
            "items": ["Nusantara Capital", "Ledgerworks"]
        }
    ]
}
```

#### `CustomBlock` (e.g. `home.community-showcase`)

```json
{
    "feed": [
        {
            "initials": "AR",
            "name": "Andra",
            "message": "Funding rates reset...",
            "timeAgo": "2m"
        }
    ],
    "voiceRoom": { "label": "On-chain Analysis Live", "participants": 6 },
    "pinnedMessage": "📌 Weekly class starts 20:00 WIB in the Main Stage voice room.",
    "mentorReply": {
        "quote": "Position sizing before conviction...",
        "attribution": "Rizky · Head of Research"
    },
    "studyGroup": {
        "name": "Technical Analysis Cohort 12",
        "week": 6,
        "totalWeeks": 8
    },
    "ticker": ["Weekly class · Thursday 20:00", "New research · ETF flows"]
}
```

---

#### `FeaturedArticleBlock` (e.g. `home.featured-article`, `articles.featured`)

```json
{
    "title": "Why liquidity leads price, and how to track it",
    "category": "Macro",
    "author": "Rizky Pratama",
    "readTime": "8 min",
    "image": {
        "url": "https://...",
        "alt": "Article cover",
        "width": 1200,
        "height": 800
    },
    "excerpt": "A deep dive into macro liquidity cycles and their impact on crypto markets.",
    "cta": {
        "label": "Read article",
        "href": "/articles/liquidity-leads-price"
    }
}
```

#### `FeaturedEventBlock` (e.g. `events.featured-event`)

```json
{
    "title": "Macro Outlook Q4 Live Webinar",
    "description": "A two-hour session on liquidity, rates and how they shape crypto risk appetite...",
    "speaker": "Rizky Pratama",
    "seats": 42,
    "seatsTaken": 12,
    "place": "Online · Zoom",
    "date": "12 Sep",
    "time": "20:00 WIB",
    "agenda": ["Liquidity cycle", "Rates & risk assets", "Positioning Q&A"],
    "image": {
        "url": "https://...",
        "alt": "Macro Outlook Q4 Live Webinar",
        "width": 1200,
        "height": 800
    },
    "cta": { "label": "Register now", "href": "/checkout" }
}
```

#### `NewsletterBlock` (e.g. `articles.newsletter`)

```json
{
    "title": "One email a week. Only the signal.",
    "description": "Research drops land in Discord before the blog.",
    "placeholder": "you@email.com",
    "buttonLabel": "Join"
}
```

#### `FooterBlock` (e.g. `global.footer`)

Managed via `GET /api/site-settings` `footer` key. Not served through content blocks.

#### `NavBlock` (e.g. `global.nav`)

Managed via `GET /api/site-settings` `nav` key. Not served through content blocks.

#### `SiteSettingsBlock` (e.g. `global.settings`)

Placeholder block for general site-level settings. Values served through `GET /api/site-settings`.

---

### 6.3 Complete Page-Section Inventory

Every block currently seeded in the database:

**`home`** — 12 sections

| Section              | Block Type         |
| -------------------- | ------------------ |
| `hero`               | `HeroBlock`        |
| `why-volubit`        | `CardGridBlock`    |
| `community-showcase` | `CustomBlock`      |
| `mentors`            | `MentorBlock`      |
| `upcoming-events`    | `EventBlock`       |
| `gallery`            | `GalleryBlock`     |
| `research-preview`   | `ResearchBlock`    |
| `latest-articles`    | `ArticleBlock`     |
| `partnership`        | `PartnerBlock`     |
| `testimonials`       | `TestimonialBlock` |
| `collab-cta`         | `CTABlock`         |
| `join-cta`           | `CTABlock`         |

**`community`** — 10 sections

| Section        | Block Type         |
| -------------- | ------------------ |
| `hero`         | `HeroBlock`        |
| `stats`        | `StatsBlock`       |
| `activities`   | `CardGridBlock`    |
| `schedule`     | `ScheduleBlock`    |
| `faq`          | `FAQBlock`         |
| `showcase`     | `CustomBlock`      |
| `instructors`  | `MentorBlock`      |
| `gallery`      | `GalleryBlock`     |
| `testimonials` | `TestimonialBlock` |
| `join-cta`     | `CTABlock`         |

**`about`** — 8 sections

| Section       | Block Type      |
| ------------- | --------------- |
| `hero`        | `HeroBlock`     |
| `vision`      | `RichTextBlock` |
| `mission`     | `RichTextBlock` |
| `timeline`    | `ScheduleBlock` |
| `culture`     | `CardGridBlock` |
| `instructors` | `MentorBlock`   |
| `gallery`     | `GalleryBlock`  |
| `join-cta`    | `CTABlock`      |

**`events`** — 8 sections

| Section           | Block Type           |
| ----------------- | -------------------- |
| `hero`            | `HeroBlock`          |
| `featured-event`  | `FeaturedEventBlock` |
| `calendar`        | `ScheduleBlock`      |
| `upcoming-events` | `EventBlock`         |
| `instructors`     | `MentorBlock`        |
| `gallery`         | `GalleryBlock`       |
| `testimonials`    | `TestimonialBlock`   |
| `join-cta`        | `CTABlock`           |

**`articles`** — 1 section

| Section | Block Type |
|---|---|
| `hero` | `HeroBlock` |

> `NewsletterBlock` (`articles.newsletter`) is defined in the enum but not yet seeded.

**`faq`** — 2 sections

| Section      | Block Type      |
| ------------ | --------------- |
| `hero`       | `HeroBlock`     |
| `faq-groups` | `FAQGroupBlock` |

**`membership`** — 3 sections

| Section    | Block Type     |
| ---------- | -------------- |
| `hero`     | `HeroBlock`    |
| `plans`    | `PricingBlock` |
| `join-cta` | `CTABlock`     |

**`membership-detail`** — 8 sections

| Section               | Block Type         |
| --------------------- | ------------------ |
| `hero`                | `HeroBlock`        |
| `research-dashboard`  | `ResearchBlock`    |
| `courses`             | `CardGridBlock`    |
| `learning-journey`    | `ScheduleBlock`    |
| `membership-benefits` | `CardGridBlock`    |
| `community-showcase`  | `CustomBlock`      |
| `instructors`         | `MentorBlock`      |
| `testimonials`        | `TestimonialBlock` |

**`checkout`** — 1 section

| Section | Block Type  |
| ------- | ----------- |
| `hero`  | `HeroBlock` |

**`partnership`** — 5 sections

| Section        | Block Type      |
| -------------- | --------------- |
| `hero`         | `HeroBlock`     |
| `partners`     | `PartnerBlock`  |
| `benefits`     | `CardGridBlock` |
| `process`      | `CardGridBlock` |
| `inquiry-form` | `RichTextBlock` |

**`contact`** — 4 sections

| Section    | Block Type      |
| ---------- | --------------- |
| `hero`     | `HeroBlock`     |
| `channels` | `ContactBlock`  |
| `office`   | `RichTextBlock` |
| `faq`      | `FAQBlock`      |

### 6.4 Site Settings Keys

| Key               | Description                                                                                                                                                                             |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nav`             | Navigation links — `{items: [{label, href, children?: [{label, href}]}]}`                                                                                                               |
| `footer`          | Full footer config — `{footerDescription, footerCopyright, footerLinks: {columns: [{heading?, links: [{label, href}]}], socialLinks: [{platform, href}], legalLinks: [{label, href}]}}` |
| `branding`        | Brand assets — `{siteName, tagline, logo: string (URL), favicon: string (URL)}`                                                                                                         |
| `announcementBar` | Top banner — `{enabled: boolean, text: string, href?: string}`                                                                                                                          |
| `seo`             | SEO defaults — `{defaultMetaTitle?: string, defaultMetaDescription?: string, defaultOgImage?: string (URL), socialLinks: {discord, telegram, twitter, instagram, linkedin, youtube}}`   |

---

## 7. Webhooks

When a `webhook_url` is configured in `config/cms.php`, the API dispatches POST requests to that URL on the following events:

```json
{
    "event": "content.updated",
    "timestamp": "2026-08-23T10:30:00+07:00",
    "payload": {
        "block_id": "uuid",
        "page": "home",
        "section": "hero",
        "block_type": "HeroBlock",
        "admin_id": "uuid"
    }
}
```

| Event                   | Trigger                           | Key Payload Fields                          |
| ----------------------- | --------------------------------- | ------------------------------------------- |
| `content.updated`       | `PUT /api/admin/content/{id}`     | `block_id`, `page`, `section`, `block_type` |
| `article.created`       | `POST /api/admin/articles`        | `article_id`, `title`                       |
| `article.updated`       | `PUT /api/admin/articles/{id}`    | `article_id`, `title`                       |
| `article.deleted`       | `DELETE /api/admin/articles/{id}` | `article_id`                                |
| `event.created`         | `POST /api/admin/events`          | `event_id`, `title`                         |
| `event.updated`         | `PUT /api/admin/events/{id}`      | `event_id`, `title`                         |
| `event.deleted`         | `DELETE /api/admin/events/{id}`   | `event_id`                                  |
| `site_settings.updated` | `PUT /api/admin/site-settings`    | `key`, `admin_id`                           |
| `newsletter.subscribed` | `POST /api/newsletter`            | `email`                                     |
| `contact.received`      | `POST /api/contact`               | `name`, `email`, `subject`                  |
| `partnership.inquiry`   | `POST /api/partnership/inquiry`   | `company`, `email`                          |
| `checkout.started`      | `POST /api/checkout/register`     | `member_id`, `plan`, `email`                |

**Retry behavior:** Webhooks are fire-and-forget. If the endpoint is unreachable, the error is logged and the API continues normally. No retries in v1.

---

## 8. Environment Variables

| Variable                                                          | Description                     | Example                           |
| ----------------------------------------------------------------- | ------------------------------- | --------------------------------- |
| `APP_ENV`                                                         | Environment                     | `production`, `local`             |
| `APP_URL`                                                         | Application base URL            | `https://api.volubit.io`          |
| `CMS_ADMIN_API_TOKEN`                                             | Static token for admin API auth | `sk_live_...`                     |
| `CMS_WEBHOOK_URL`                                                 | URL to dispatch webhooks to     | `https://your-cdn.com/revalidate` |
| `PAYMENT_GATEWAY_URL`                                           | Payment gateway base URL for checkout redirect | `https://payment.gateway.example` |
| `DB_CONNECTION`                                                   | Database driver                 | `mysql`, `pgsql`                  |
| `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` | Database connection             | —                                 |
| `SESSION_DRIVER`                                                  | Session driver                  | `database`, `file`, `cookie`      |
| `SESSION_LIFETIME`                                                | Session expiry in minutes       | `120`                             |
| `CACHE_STORE`                                                     | Cache driver                    | `file`, `database`, `redis`       |

> **Webhook setup:** Set `CMS_WEBHOOK_URL` to your CDN/ISR revalidation endpoint. On each content save, the API will POST to this URL so the CDN can purge stale cached pages immediately (rather than waiting for the 5-minute TTL).

---

## 9. FE Integration Guide

This section maps each frontend page to the API endpoints used to populate it.

### 9.1 Existing Endpoints (Ready to Integrate)

All of these endpoints are implemented in the BE and can be integrated immediately.

| FE Page                    | API Call                                     | Endpoint      | Data Needed                                                            |
| -------------------------- | -------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
| `/` (Home)                 | `GET /api/landing/home`                      | Landing API   | Hero, sections, featured articles, upcoming events                     |
| `/community`               | `GET /api/landing/community`                 | Landing API   | Hero, stats, activities, schedule, FAQ, mentors, gallery, testimonials |
| `/about`                   | `GET /api/landing/about`                     | Landing API   | Hero, vision/mission, timeline, culture, mentors, gallery              |
| `/events`                  | `GET /api/landing/events`                    | Landing API   | Hero, featured event, calendar, upcoming events                        |
| `/events` (featured event) | `GET /api/events?featured=true&limit=1`      | Events API    | Featured event card                                                    |
| `/events` (upcoming)       | `GET /api/events?status=upcoming&limit=3`    | Events API    | 3 upcoming events for section                                          |
| `/events` (past)           | `GET /api/events?status=past`                | Events API    | Past event recordings                                                  |
| `/events/{slug}`           | `GET /api/events/{slug}`                     | Events API    | Single event detail                                                    |
| `/articles`                | `GET /api/articles?limit=10`                 | Articles API  | Article list                                                           |
| `/articles` (filter)       | `GET /api/articles?category={slug}`          | Articles API  | Filter by category                                                     |
| `/articles` (search)       | `GET /api/articles?search={term}`            | Articles API  | Search results                                                         |
| `/articles/featured`       | `GET /api/articles/featured`                 | Articles API  | Top featured article                                                   |
| `/articles/{slug}`         | `GET /api/articles/{slug}`                   | Articles API  | Single article                                                         |
| `/articles/categories`     | `GET /api/article-categories`               | Article Categories API | All categories (name, slug, description)                    |
| `/articles/categories/{slug}` | `GET /api/article-categories/{slug}`       | Article Categories API | Single category                                |
| `/faq`                     | `GET /api/landing/faq`                       | Landing API   | Hero + FAQ groups                                                      |
| `/membership`              | `GET /api/landing/membership`                | Landing API   | Hero, plans                                                            |
| `/membership/pricing`      | `GET /api/content/membership/plans`          | Content API   | Pricing plans matrix                                                   |
| `/partnership`             | `GET /api/landing/partnership`               | Landing API   | Hero, partners, benefits, process, inquiry form                        |
| `/contact`                 | `GET /api/landing/contact`                   | Landing API   | Hero, channels, office, FAQ                                            |
| Nav                        | `GET /api/site-settings` → `nav`             | Site Settings | Navigation links                                                       |
| Footer                     | `GET /api/site-settings` → `footer`          | Site Settings | Footer links, social, legal                                            |
| SEO/Head                   | `GET /api/site-settings` → `seo`, `branding` | Site Settings | Meta tags, OG image, site name                                         |
| Newsletter signup form      | `POST /api/newsletter`                       | Newsletter API | Subscribe email                                                  |
| Contact form               | `POST /api/contact`                          | Contact API   | Submit contact form                                                 |
| Partnership form            | `POST /api/partnership/inquiry`              | Partnership API | Submit partnership inquiry                                        |
| Checkout registration      | `POST /api/checkout/register`               | Checkout API  | Register member + initiate payment                                    |

#### SSR Pattern (Recommended)

For server-side rendering, use `GET /api/landing/{page}` to fetch all page data in a single request:

```typescript
// Example: Load home page data in a TanStack Start loader
const homeData = await fetch(`${API_BASE}/api/landing/home`);
const { nav, blocks, featuredArticles, upcomingEvents } = await homeData.json();
```

#### Client-Side Pattern

For client-side fetching with TanStack Query:

```typescript
// Example: Fetch articles list with category filter
const { data } = useQuery({
    queryKey: ["articles", category],
    queryFn: () =>
        fetch(`${API_BASE}/api/articles?category=${category}`).then((res) =>
            res.json(),
        ),
});
```

### 9.2 FE Integration Status

| FE Page                      | Endpoint                        | Status           |
| ---------------------------- | ------------------------------- | ---------------- |
| Articles newsletter signup   | `POST /api/newsletter`          | **Implemented**   |
| Contact form                 | `POST /api/contact`             | **Implemented**   |
| Partnership inquiry           | `POST /api/partnership/inquiry` | **Implemented**   |
| Member checkout registration | `POST /api/checkout/register`   | **Implemented**   |
| Payment confirmation webhook | External payment gateway → BE   | **Pending**       |

> Payment webhook: The external payment gateway calls back to a `POST /api/webhooks/payment` endpoint that marks the member as paid. Not yet implemented — wire it after the payment gateway is configured.
#### Articles Newsletter — FE Wiring

Wire this in `src/routes/articles.tsx`:

```typescript
// Line ~70 — replace onSubmit handler
const handleNewsletterSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.currentTarget;
    const email = new FormData(form).get("email") as string;
    const res = await fetch("/api/newsletter", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
    });
    if (res.ok) {
        toast.success("Subscribed! Check your email.");
        form.reset();
    } else {
        const data = await res.json();
        toast.error(data.errors?.email?.[0] ?? "Something went wrong.");
    }
};
```

#### Contact Form — FE Wiring

Wire this in `src/routes/contact.tsx` form submit:

```typescript
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.currentTarget));
    const res = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
    });
    if (res.ok) {
        toast.success("Message received. We'll reply within 1 business day.");
        e.currentTarget.reset();
    }
};
```

#### Partnership Inquiry — FE Wiring

Wire this in `src/routes/partnership.tsx` form submit.

#### Checkout Registration — FE Wiring

Wire the 4-step checkout flow:

```typescript
// Step 1 (plan selection) → Step 2 (info) → Step 3 (payment) → Step 4 (confirmation)
// On "Continue" from step 2, call the registration endpoint:
const res = await fetch("/api/checkout/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
        plan,
        name,
        email,
        whatsapp,
        discord,
        telegram,
        coupon,
    }),
});
const { data } = await res.json();
// Store member_id, then redirect to payment gateway URL from data.payment_url
// On payment webhook/callback, mark member as active and show confirmation
```

### 9.3 Environment Variables (FE)

The frontend needs these env vars to connect to the API:

```env
# .env
PUBLIC_API_BASE_URL=https://api.volubit.io/api
```

```typescript
// src/lib/api.ts
export const API_BASE = import.meta.env.PUBLIC_API_BASE_URL ?? "/api";
```

### 9.4 Block Type to Component Mapping

| Block Type             | Data Shape Key                                                           | FE Component (planned)                 |
| ---------------------- | ------------------------------------------------------------------------ | -------------------------------------- |
| `HeroBlock`            | `hero`                                                                   | `Hero.tsx`                             |
| `StatsBlock`           | `stats`                                                                  | `StatsSection.tsx`                     |
| `CardGridBlock`        | `why-volubit`, `activities`, `culture`, `benefits`, `process`, `courses` | `CardGrid.tsx`                         |
| `ScheduleBlock`        | `schedule`, `timeline`, `calendar`, `learning-journey`                   | `Schedule.tsx`                         |
| `FAQBlock`             | `faq`                                                                    | `FAQAccordion.tsx`                     |
| `FAQGroupBlock`        | `faq-groups`                                                             | `FAQGroup.tsx`                         |
| `TestimonialBlock`     | `testimonials`                                                           | `Testimonials.tsx`                     |
| `EventBlock`           | `upcoming-events`                                                        | `UpcomingEvents.tsx`                   |
| `FeaturedEventBlock`   | `featured-event`                                                         | `FeaturedEvent.tsx`                    |
| `ArticleBlock`         | `latest-articles`                                                        | `LatestArticles.tsx`                   |
| `FeaturedArticleBlock` | `featured`                                                               | `FeaturedArticle.tsx`                  |
| `MentorBlock`          | `mentors`, `instructors`                                                 | `Instructors.tsx`                      |
| `ResearchBlock`        | `research-preview`, `research-dashboard`                                 | `ResearchPreview.tsx`                  |
| `GalleryBlock`         | `gallery`                                                                | `CommunityGallery.tsx`                 |
| `PricingBlock`         | `plans`                                                                  | `PricingPlans.tsx`                     |
| `CTABlock`             | `join-cta`, `collab-cta`                                                 | `JoinCTA.tsx` / `CollaborationCTA.tsx` |
| `RichTextBlock`        | `vision`, `mission`, `office`                                            | `RichText.tsx`                         |
| `ContactBlock`         | `channels`                                                               | `ContactChannels.tsx`                  |
| `PartnerBlock`         | `partnership`, `partners`                                                | `Partnership.tsx`                      |
| `CustomBlock`          | `community-showcase`, `showcase`                                         | `CommunityShowcase.tsx`                |
| `NewsletterBlock`      | `newsletter`                                                             | `NewsletterForm.tsx` (pending)         |
