> ## Documentation Index
> Fetch the complete documentation index at: https://docs.commentify.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# JSON API

> Versioned commentify/api/v1: list, create, edit, like, report, pin, media, markdown preview, and UI config.

All routes live under `commentify/api/v1` (configurable via `api.prefix`). Middleware is `api.middleware` plus `ResolveCommentifyGuard`. Writes also use `EnsureCommentifyWritable` (read-only → `423`).

Rate limiters: `commentify-api-read` (default 300/min) and `commentify-api-write` (default 60/min), keyed by user id or IP.

`{type}` is a [commentable alias](/usage), never a class name. Unknown aliases 404 with "Unknown commentable type."

## Endpoints

| Method | Path                    | Auth          | Purpose                           |
| ------ | ----------------------- | ------------- | --------------------------------- |
| GET    | `ui`                    | none          | Labels, feature flags, auth state |
| GET    | `{type}/{id}/comments`  | none          | Paginated parent comments         |
| POST   | `{type}/{id}/comments`  | user or guest | Create comment / reply            |
| GET    | `comments/{id}/replies` | none          | Paginated replies                 |
| PATCH  | `comments/{id}`         | owner         | Edit own comment                  |
| DELETE | `comments/{id}`         | owner         | Delete own comment (`204`)        |
| POST   | `comments/{id}/like`    | optional      | Toggle like                       |
| POST   | `comments/{id}/report`  | optional      | Report a comment                  |
| POST   | `comments/{id}/pin`     | moderator     | Pin a parent comment              |
| DELETE | `comments/{id}/pin`     | moderator     | Unpin                             |
| POST   | `markdown/preview`      | none          | Render markdown as Blade does     |
| GET    | `users?q=`              | **required**  | `@mention` autocomplete           |
| POST   | `media`                 | user / guest  | Image upload when media enabled   |

Named routes use the `commentify-pro.*` prefix (`commentify-pro.comments.index`, …).

## `GET ui`

Keeps frontends honest. Labels come from `trans('commentify::commentify.comments')`. Feature flags mirror config so turning off sorting or reporting affects every stack. Also reports whether the visitor may comment (read-only, bans, guests).

```json theme={null}
{
  "data": {
    "labels": { "comment": "Comment", "reply": "Reply" },
    "features": {
      "sorting": true,
      "reporting": true,
      "nesting": true,
      "read_only": false,
      "emoji_picker": true,
      "markdown_toolbar": true,
      "markdown_preview": true,
      "guests": false,
      "media": false,
      "subscriptions": true,
      "realtime": false,
      "badges": true,
      "pins": true
    },
    "report_reasons": ["spam", "inappropriate", "offensive", "other"],
    "theme": "auto",
    "default_sort": "newest",
    "per_page": 10,
    "users_route_prefix": "users",
    "guest": { "require_email": true, "show_gravatar": true },
    "auth": {
      "authenticated": false,
      "banned": false,
      "can_comment": false,
      "user": null
    }
  }
}
```

## List comments

`GET /commentify/api/v1/articles/1/comments`

Query:

* `sort` — `newest` (default from config), `oldest`, `most_liked`, `most_replied`
* `page`, `per_page` (max 50)
* `cursor` — cursor pagination when sort is `newest` or `oldest`

Parents are returned with up to `api.replies_per_parent` (default 3) children. Pinned parents sort first (`pinnedFirst()`). Unapproved comments are omitted except the current user's own.

Standard Laravel pagination JSON (`data`, `links`, `meta`). Cursor paginator when `cursor` is present.

## Create

`POST /commentify/api/v1/articles/1/comments` → `201`

```json theme={null}
{
  "body": "Hello **world**",
  "parent_id": null,
  "subscribe_to_replies": false,
  "guest_name": "Alex",
  "guest_email": "alex@example.com"
}
```

| Field                        | Rules                                                                     |
| ---------------------------- | ------------------------------------------------------------------------- |
| `body`                       | required, string, max 10000                                               |
| `parent_id`                  | nullable, must exist on `comments` and belong to this thread              |
| `subscribe_to_replies`       | optional boolean — [Notifications](/notifications)                        |
| `guest_name` / `guest_email` | required for guests when `allow_guests` (email per `guest.require_email`) |

`is_approved` is `true` unless `require_approval` is on **or** the spam pipeline returns **review**. Pipeline **deny** aborts the save (`save()` returns false) → `spam_rejected`.

## Update / delete

`PATCH` body: `{ "body": "…" }` (max 10000). Spam pipeline runs again on body edits.

`DELETE` returns `204`.

## Like

`POST .../like` toggles. Response:

```json theme={null}
{ "liked": true, "likes_count": 4 }
```

Authenticated likes key on `user_id`. Guests key on IP + user agent when `api.guest_likes` is true.

## Report

```json theme={null}
{ "reason": "spam", "additional_details": null }
```

`reason` must be in `core.report_reasons`. Details (max 500) are appended as `reason: details` in storage, matching Livewire. Duplicate reports → `already_reported` (409). `201` `{ "reported": true }`. Disabled reporting → 404.

## Pin

`POST` / `DELETE .../pin`. Only parent comments. One pin per thread (pinning another clears the previous). Disabled pins → 404. Non-moderators → 403.

## Markdown preview

```json theme={null}
{ "body": "Hi @maya" }
```

Returns `{ "data": { "html": "…" } }` using `CommentPresenter::preview()` — same CommonMark + mention linking as published comments. Empty body → empty HTML.

## User search

`GET users?q=may` — authenticated only. `q` 1–50 chars. `%` and `_` are escaped. Max **5** rows. `{ "data": [ { "id", "name", "avatar" } ] }`.

## Media

`POST media` as `multipart/form-data` field `file`. See [Image uploads](/media). `201` `{ "url", "thumbnail_url" }`. Disabled → 404.

## Comment resource

```json theme={null}
{
  "id": 12,
  "body": "Hello **world**",
  "html": "<p>Hello <strong>world</strong></p>",
  "parent_id": null,
  "is_approved": true,
  "user": { "id": 3, "name": "Maya Chen", "avatar": "https://…" },
  "likes_count": 2,
  "liked": false,
  "reported": false,
  "replies_count": 1,
  "replies": [],
  "can": { "update": false, "delete": false, "pin": false },
  "is_author": true,
  "is_moderator": false,
  "is_guest": false,
  "pinned": true,
  "pinned_at": "2026-09-12T12:00:00+00:00",
  "created_at": "2026-09-12T12:00:00+00:00",
  "relative_created_at": "2 minutes ago",
  "updated_at": "2026-09-12T12:00:00+00:00"
}
```

Guest authors: `user.id` is `null`, `name` from `guest_name`, avatar from Gravatar when configured.

## Error codes

JSON:

```json theme={null}
{
  "message": "Comment rejected by the spam filter.",
  "code": "spam_rejected",
  "errors": {}
}
```

| `code`             | HTTP | Meaning                     |
| ------------------ | ---- | --------------------------- |
| `spam_rejected`    | 422  | Checker **deny**            |
| `read_only`        | 423  | Writes disabled             |
| `comment_banned`   | 403  | User is comment-banned      |
| `unauthenticated`  | 401  | Missing user where required |
| `already_reported` | 409  | Duplicate report            |
| `nesting_disabled` | 422  | Replies turned off          |
| `parent_mismatch`  | 422  | Parent is another thread    |
| `cannot_pin_reply` | 422  | Only top-level pins         |

Validation failures are ordinary Laravel `422` with `errors`. File too large → `413`. Upload rate limit → `429`.

Inherited from core: sort, pagination, approval, `CommentPolicy`, read-only, bans. See [Authorization](/authorization).
