---
doc: API Conventions
version: 1.0.0
last_updated: 2026-11-12
applies_to: [admin, client, trainer, center, provider]
decisions: [TD-01, TD-02]
---

# API Conventions

The contract every app shares: headers, the response envelope, errors,
pagination, data formats, and the client setup for Flutter and React. Read it
once before any feature doc. Feature docs only describe what differs.

> This describes the base project as the code behaves today. The rules in
> `.kiro/steering/api-surfaces.md` (enum objects, ISO dates, money strings,
> UUIDs…) are applied feature by feature.

## Version history

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.0.0 | 2026-11-12 | MAJOR | Public content and countries moved to shared URLs. | Remove `/client` from the four paths below. |
| 0.1.0 | 2026-11-11 | Initial | Base conventions from the code: envelope, errors, pagination, headers, Flutter/React setup. | Build the API client as in §5 / §6. |

---

## Public shared content

Guest GET endpoints (no authentication): `/api/v1/faqs` (`shared.content.faqs`), `/api/v1/pages/{type}` (`shared.content.page`), `/api/v1/sliders` (`shared.content.sliders`), and `/api/v1/countries` (`shared.geo.countries`). These routes use `geo.context`. Apps must remove `/client` from their previous URLs.

## 1. Request headers

| Header | Required | Value | Why |
|--------|----------|-------|-----|
| `Accept` | Yes | `application/json` | Guarantees JSON errors instead of HTML pages. |
| `Authorization` | Protected routes | `Bearer {token}` | JWT returned by the surface's login / verify endpoint. |
| `Accept-Language` | Recommended | `ar` or `en` | Language of `message` and of translated fields. |
| `X-Correlation-ID` | Optional | UUID | Echoed in logs and domain errors — send one per request to make bug reports traceable. |
| `Idempotency-Key` | Routes behind the `idempotency` middleware | UUID per user action | A retry with the same key returns the first result. Reuse it on retry; new key for a new action. |
| `X-Country` / `X-City` / `X-Currency` | Optional | e.g. `SA` | Overrides geo detection on routes with `geo.context`. |

## 2. Response envelope

Every response, success or failure, has the same three keys (`json()` helper,
`App\Core\Response\ApiResponse`):

```json
{ "status": "success | fail | error", "message": "Human-readable text in Accept-Language", "data": null }
```

| `status` | HTTP | Meaning |
|----------|------|---------|
| `success` | 2xx | The action worked; `data` holds the result. |
| `fail` | 4xx | The request was refused (validation, auth, not found, rate limit). Show `message`. |
| `error` | 4xx / 5xx | A typed domain or infrastructure error; branch on `data.error_code`. |

### 2.1 Paginated list (admin CRUD index)

Admin list endpoints (`BaseCRUDService::index()`) put Laravel's resource
collection inside `data`: the rows in `data.data`, links in `data.links`, and the
counters in `data.meta`.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "data": [ { "id": 1, "…": "…" } ],
    "links": { "first": "…?page=1", "last": "…?page=3", "prev": null, "next": "…?page=2" },
    "meta": { "current_page": 1, "from": 1, "last_page": 3, "path": "…", "per_page": 15, "to": 15, "total": 42 }
  }
}
```

> Shape taken from `app/Core/Service/BaseCRUDService.php` (`Resource::collection($result)->response()->getData()`),
> not from a captured response. Replace it with a captured example when the first list feature is tested.

### 2.2 List query parameters

| Param | Example | Effect |
|-------|---------|--------|
| `page` | `1` | Page number. |
| `per_page` | `15` | Rows per page (max 100). |
| `paginate` | `false` | Return every row, no `links` / `meta`. |
| `search` | `ali` | Search across the model's searchable fields. |
| `filters[key]` | `filters[is_active]=1` | Exact filters the feature doc lists. |
| `sort[field]` | `sort[created_at]=desc` | Sort the feature doc allows. |

## 3. Errors

### 3.1 Validation — HTTP 422

The first validation message, `data` is `null`. Captured from `POST /api/v1/admin/auth/login` with an empty body:

```json
{"status":"fail","message":"The Email field is required.","data":null}
```

### 3.2 Domain error — HTTP 4xx / 5xx

Thrown as `App\Core\Exceptions\DomainException` subclasses. `data.error_code`
is stable — apps branch on it; `data.correlation_id` is present when the request
sent one; `data.context` appears only in debug mode.

```json
{ "status": "error", "message": "…", "data": { "error_code": "BUSINESS_RULE_VIOLATED", "correlation_id": "…" } }
```

Every error code (`app/Core/Exceptions/ErrorCode.php`):

| `error_code` | Usual HTTP | Meaning |
|--------------|------------|---------|
| `ENTITY_NOT_FOUND` | 404 | Requested record does not exist. |
| `ENTITY_ALREADY_EXISTS` | 409 / 422 | Uniqueness violation. |
| `BUSINESS_RULE_VIOLATED` | 422 | A business rule refused the action. |
| `INVALID_STATE_TRANSITION` | 409 | Not allowed in the record's current status. |
| `VALIDATION_FAILED` | 422 | Request refused by a service-level check. |
| `EMPTY_PAYLOAD` | 422 | Required data was empty. |
| `UNAUTHENTICATED` | 401 | No or invalid token. |
| `PERMISSION_DENIED` | 403 | The account lacks the permission. |
| `ACCOUNT_INACTIVE` | 403 | Account inactive or banned. |
| `INFRASTRUCTURE_FAILURE` | 503 | An external service failed. |
| `CIRCUIT_OPEN` | 503 | Circuit breaker open for that service — retry later. |
| `MISSING_BINDING` | 500 | Server misconfiguration. |
| `RATE_LIMITED` | 429 | Too many requests. |
| `IDEMPOTENCY_CONFLICT` | 409 | Same `Idempotency-Key` reused with a different request. |
| `PAYMENT_FAILED` | 402 / 422 | Payment processing failed. |
| `WEBHOOK_INVALID_SIGNATURE` | 401 | Webhook signature check failed. |
| `INTERNAL_ERROR` | 500 | Unexpected error. |

### 3.3 Framework errors

| HTTP | `message` | When |
|------|-----------|------|
| 401 | `Unauthenticated, you have to login first` | Missing / expired token. |
| 403 | `You are not authorized to make this action` | Policy / RBAC refusal. |
| 404 | `Resource not found` | Unknown route or record. |
| 405 | `HTTP method not allowed` | Wrong method. |
| 429 | `Too many requests, please try again after a while!` | Throttled. |

Captured from `GET /api/v1/nothing-here`:

```json
{"status":"fail","message":"Resource not found","data":null}
```

## 4. Data formats

Targets for every new or touched feature (`.kiro/steering/api-surfaces.md`);
base endpoints may still differ until their feature is built.

| Value | Format |
|-------|--------|
| Keys | `snake_case`; every documented key always present (`null` when empty) |
| Enums / statuses | `{ "value": "active", "label": "نشط" }` — branch on `value`, display `label` |
| Money | 2-decimal string, e.g. `"150.00"` |
| Dates | ISO 8601 with offset (`2026-11-11T14:30:00+03:00`); date-only `YYYY-MM-DD` |
| Media | Absolute URLs |
| Allowed actions | `can_*` booleans returned by the backend |

## 5. Flutter integration

### 5.1 Dio client

```dart
final dio = Dio(BaseOptions(
  baseUrl: '${Env.host}/api/v1/client', // the surface's prefix from PROJECT_BRAIN.md §2
  headers: {'Accept': 'application/json'},
  connectTimeout: const Duration(seconds: 15),
  receiveTimeout: const Duration(seconds: 30),
));

dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) async {
    final token = await tokenStorage.read();
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    options.headers['Accept-Language'] = appLocale.languageCode; // 'ar' | 'en'
    options.headers['X-Correlation-ID'] = const Uuid().v4();
    handler.next(options);
  },
  onError: (e, handler) async {
    // 401: try POST auth/refresh once, then log out
    if (e.response?.statusCode == 401 && !await authController.tryRefresh()) await authController.logout();
    handler.next(e);
  },
));
```

### 5.2 Envelope parsing

```dart
class ApiResponse<T> {
  final String status;          // success | fail | error
  final String message;
  final T? data;
  final String? errorCode;      // data.error_code on domain errors

  bool get isSuccess => status == 'success';

  factory ApiResponse.fromJson(Map<String, dynamic> json, T Function(dynamic)? parse) {
    final raw = json['data'];
    return ApiResponse(
      status: json['status'] as String,
      message: (json['message'] ?? '') as String,
      data: json['status'] == 'success' && parse != null && raw != null ? parse(raw) : null,
      errorCode: raw is Map<String, dynamic> ? raw['error_code'] as String? : null,
    );
  }
  // constructor omitted
}

class Paginated<T> {
  final List<T> items;
  final int currentPage, lastPage, total;
  bool get hasMore => currentPage < lastPage;

  /// `data` of a paginated response: rows in data['data'], counters in data['meta'] (§2.1).
  factory Paginated.fromJson(Map<String, dynamic> data, T Function(Map<String, dynamic>) item) {
    final meta = data['meta'] as Map<String, dynamic>;
    return Paginated(
      items: (data['data'] as List).map((e) => item(e as Map<String, dynamic>)).toList(),
      currentPage: meta['current_page'] as int,
      lastPage: meta['last_page'] as int,
      total: meta['total'] as int,
    );
  }
  // constructor omitted
}
```

### 5.3 Rules for the Flutter apps

- Drive buttons from the `can_*` flags in responses, not from local status logic.
- Show `message` from failures directly; switch behavior on `errorCode`.
- Never hard-code ids or tokens from doc examples.
- Build RTL-first when Arabic is a supported locale.

## 6. React integration

### 6.1 Axios client

```ts
import axios from 'axios';

export const api = axios.create({
  baseURL: `${import.meta.env.VITE_API_HOST}/api/v1/admin`,
  headers: { Accept: 'application/json' },
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem('admin_token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  config.headers['Accept-Language'] = i18n.language; // 'ar' | 'en'
  config.headers['X-Correlation-ID'] = crypto.randomUUID();
  return config;
});

api.interceptors.response.use(
  (res) => res,
  (err) => (err.response?.status === 401 ? authStore.refreshOrLogout(err) : Promise.reject(err)),
);
```

### 6.2 Shared types

```ts
export type ApiStatus = 'success' | 'fail' | 'error';

export interface ApiResponse<T> { status: ApiStatus; message: string; data: T; }

export interface ApiErrorData { error_code?: string; correlation_id?: string; }

export interface PaginationMeta {
  current_page: number; from: number | null; last_page: number;
  path: string; per_page: number; to: number | null; total: number;
}

export interface Paginated<T> {
  data: T[];
  links: { first: string; last: string; prev: string | null; next: string | null };
  meta: PaginationMeta;
}

export interface EnumValue<V extends string = string> { value: V; label: string; }
export type Money = string; // "150.00"
```

### 6.3 Table query helper

```ts
export interface ListQuery {
  page?: number; per_page?: number; search?: string;
  filters?: Record<string, string | number | boolean>;
  sort?: Record<string, 'asc' | 'desc'>;
}

export function toParams(q: ListQuery) {
  const p: Record<string, unknown> = { page: q.page, per_page: q.per_page, search: q.search };
  Object.entries(q.filters ?? {}).forEach(([k, v]) => (p[`filters[${k}]`] = v));
  Object.entries(q.sort ?? {}).forEach(([k, v]) => (p[`sort[${k}]`] = v));
  return p;
}
```

### 6.4 Rules for the dashboard

- Admin routes are guarded by `rbac.auto`: the route name (e.g. `admin.faqs.index`)
  is the permission key. Hide menu items and buttons the admin lacks; the backend still enforces them.
- Mirror the validation rules from the feature doc in form schemas (zod / yup).

## 7. Docs for your coding agent (MCP)

The backend serves these docs to coding agents through the `project-docs` MCP
server and to people at `{HOST}/docs`. Setup: [MCP_AGENT_GUIDE.md](MCP_AGENT_GUIDE.md).

## 8. Health probe

`GET /api/v1/health` (public). Captured response — note it is **not** wrapped in
the envelope:

```json
{"status":"healthy","checks":{"database":{"status":"up","latency_ms":3.67},"cache":{"status":"up","driver":"array","latency_ms":0.49},"queue":{"status":"up","driver":"database"},"storage":{"status":"up","driver":"local"}},"version":"1.0.0","timestamp":"2026-11-11T12:03:37.383176Z"}
```
