# Martis API — Overview · Martis docs

> Martis exposes a REST API for every CRUD operation, resource metadata, navigation, search, preferences, sessions, impersonation, and admin surfaces. The schema is generated at runtime by Scramble and served as both interactive UI and raw OpenAPI 3.1.

Source: https://getmartis.com/docs/reference/api

# Martis API — Overview

Martis exposes a REST API for every CRUD operation, resource metadata, navigation, search, preferences, sessions, impersonation, and admin surfaces. The schema is generated at runtime by [Scramble](https://scramble.dedoc.co/) and served as both interactive UI and raw OpenAPI 3.1.

> **The authoritative endpoint reference is the auto-generated OpenAPI 3.1 document at `/{martis-path}/api-docs.json`** (when `MARTIS_API_DOCS_ENABLED=true`). This page is a curated index of the most-used surfaces — when in doubt, fetch the JSON, it always reflects the live route table.

## Access

| Item | Value |
| --- | --- |
| Swagger UI | `/{martis-path}/api-docs` (off by default — see toggle below) |
| Raw OpenAPI 3.1 | `/{martis-path}/api-docs.json` (off by default) |
| Base URL | `/{martis-path}/api` |
| Format | JSON |
| Auth | Session cookie (Laravel guard, configurable via `MARTIS_GUARD`). Same-origin SPA — no Bearer token, no JWT, no Sanctum. |

## Enabling the OpenAPI surface

Off by default so `composer require martis/martis` does not expose the schema publicly. Flip the env to register the routes:

```
MARTIS_API_DOCS_ENABLED=true
```

After flipping the env value, run `php artisan optimize:clear` AND restart the running PHP workers (`docker compose restart app`, `systemctl reload php-fpm`, or `php artisan octane:reload` depending on your stack). The artisan command alone clears Laravel's config cache, but PHP-FPM workers keep the parsed `.env` in process memory until they're recycled — so a "supposedly off" state can keep serving the schema until the worker pool is replaced.

That registers two routes:

- `GET /{martis-path}/api-docs` — Stoplight Elements UI.
- `GET /{martis-path}/api-docs.json` — raw OpenAPI 3.1 document.

Both go through `martis.api_docs.middleware`. The default (v2.4.0) is `null`: the Martis protected stack, the Martis guard (`martis.auth`, so `MARTIS_GUARD` counts, not the app's default guard) plus the 2FA, email verification, `viewMartis` and forced password change gates. A list you set is kept and then completed with any of those guards it leaves out, so a published `['web', 'auth']` from v2.3 or earlier never lets a user of another guard read the schema of the admin API. When the env is `false`, **the routes are not registered at all** — `php artisan route:list --path=api-docs` returns nothing, and a request hits the SPA catch-all (302 to `/martis`) rather than Scramble. That is the intended security envelope.

To tighten further (e.g. admin-only) override the middleware in your published `config/martis.php`:

```
'api_docs' => [
    'enabled' => env('MARTIS_API_DOCS_ENABLED', false),
    'path'    => env('MARTIS_API_DOCS_PATH', 'api-docs'),
    'middleware' => ['web', 'can:manage-martis-cache'], // added to the Martis stack
],
```

Recommended: leave **off in production** unless you have a reason to expose the schema. Even when on, keep the middleware tight — the document includes every Martis API path and shape.

## Authentication

Martis is **session-cookie based**. The Laravel guard sets the cookie on `attempt()` + `session()->regenerate()`. There is no JWT; no `Authorization: Bearer …` header is needed (or accepted) by any Martis endpoint. Same-origin SPA calls work out of the box. See [Authentication](https://getmartis.com/docs/auth/authentication) for the full surface.

### Login

```
POST /martis/api/auth/login
Content-Type: application/json
 
{
  "email": "admin@example.com",
  "password": "<your-password>"
}
```

**Responses:**

| Status | Body | Description |
| --- | --- | --- |
| `200` | `{ "id": 1, "name": "...", "email": "...", "avatar_url": "...", ... }` | Successful login. The user object is returned flat (no `user` wrapper). |
| `200` | `{ "two_factor_required": true, "message": "..." }` | 2FA enabled on the account while the panel uses 2FA (`MARTIS_2FA_ENABLED`, v2.8.0) — the frontend redirects to `/2fa/challenge`. |
| `422` | `{ "message": "...", "errors": {...} }` | Validation error or wrong credentials. |
| `429` | `{ "message": "Too many attempts" }` | Rate limited (per-IP + per-email composition). |

### Other auth endpoints

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/martis/api/auth/logout` | Invalidate the server-side session. |
| `GET` | `/martis/api/auth/user` | **Public.** Returns the user object when a session cookie is present, `null` for guests. Special envelope `{two_factor_pending: true, message}` when 2FA is mid-challenge. |
| `POST` | `/martis/api/auth/register` | Self-service registration when `auth.registration.enabled=true`. |
| `POST` | `/martis/api/auth/password/email` | Send password-reset link. Gated on `auth.passwordReset.enabled`. |
| `POST` | `/martis/api/auth/password/reset` | Submit new password with token. |
| `POST` | `/martis/api/auth/email/verification-notification` | Resend the verification email (throttled `6,1`). |
| `GET` | `/martis/email/verify` | Themed notice page (must be authenticated). |
| `GET` | `/martis/email/verify/{id}/{hash}` | Signed verify link target — marks `email_verified_at`. |
| `POST` | `/martis/api/auth/magic-link/request` | **v1.8.8.** Issue a passwordless sign-in token + email it. Returns `200 {ok: true}` whether or not the email exists (account-enumeration safe). |
| `GET` | `/martis/magic-link/confirm#email=…&token=…` | **v2.4.0.** The emailed link: the SPA confirmation page. Signs nobody in and does not consume the token. Since v2.6.0 the email and token are in the URL fragment, so the server never receives them on this request. |
| `POST` | `/martis/api/auth/magic-link/consume` | **v2.4.0** (was a `GET` that signed in on load). `{ email, token, replace_session? }`: verify the token, sign the user in. |
| `POST` | `/martis/api/2fa/challenge` | Submit the 6-digit TOTP (or recovery) code during the 2FA challenge. `422` wrong code, `429` rate limited (5 a minute per user, v2.4.0), `403 { two_factor_locked: true }` after consecutive wrong codes (the session ends). Not registered while `MARTIS_2FA_ENABLED` is false (v2.8.0). |
| `GET` | `/martis/sso/{provider}/redirect` | Kick off the OAuth flow. Routes only registered when `auth.sso.enabled`. |
| `GET` | `/martis/sso/{provider}/callback` | Handle the IdP callback. |

## Resource Endpoints

The list of registered resources lives in the [Navigation endpoint](https://getmartis.com/docs/reference/api#navigation-endpoints) (`/api/navigation`). Each entry carries the `uriKey` you use below.

### Index (List Records)

```
GET /martis/api/resources/{resource}
```

**Query Parameters:**

| Parameter | Example | Description |
| --- | --- | --- |
| `search` | `?search=john` | Full-text search across the `searchable()` fields the user can see (`canSee()`; v1.38.0: the hidden ones were searched too) |
| `sort` | `?sort=category:asc,position:desc` | Sort keys: a comma-separated list of `attribute[:direction]` (a missing direction is `asc`), at most 3, a repeated attribute keeps its first occurrence. Each attribute is a `sortable()` field the user can see (`canSee()`); any other key (an unknown attribute, a field that is not sortable, one the user cannot see) is dropped and the others apply (v1.38.0: a sortable field the user could not see ordered the list). The resource's `defaultSort()` keys not named break ties, then the primary key ascending. |
| `direction` | `?sort=name&direction=desc` | Direction of a lone `sort` key without a `:suffix`: `asc` (default) or `desc`; any other value, a non-string one included, means `asc` (v1.38.0: `?direction[]=` answered 500). Ignored for a list or a suffixed key. |
| `per_page` | `?per_page=25` | Records per page |
| `page` | `?page=2` | Page number |
| `trashed` | `?trashed=only` | `only` or `with` for soft-deleted records; any other value, a non-string one included, lists the records that are not trashed (v1.38.0: `?trashed[]=` answered 500) |

**Response:**

```
{
  "data": [
    {
      "id": 1,
      "name": "Admin User",
      "email": "admin@example.com",
      "_title": "Admin User",
      "_resource": { "uriKey": "users", "label": "Users" },
      "_authorization": { "authorizedToView": true, "authorizedToUpdate": true, "authorizedToDelete": false },
      "_actionAuthorization": { "publish": true, "archive": false }
    }
  ],
  "meta": {
    "current_page": 1, "last_page": 3, "per_page": 15, "total": 42, "from": 1, "to": 15
  },
  "links": { "first": "...", "last": "...", "prev": null, "next": "..." }
}
```

**Record envelope.** Every record the API sends carries `id`, its field values and the `_title`, `_resource` and `_authorization` keys. A record that hides a field (`canSeeForModel()`) leaves its value out and lists its attribute under `_hidden` (v1.38.0): `"_hidden": ["salary"]`. The key is absent when the record hides no field. It holds the attributes of the fields of the response's field list (the index fields on the index, the detail fields on the detail, the update fields with `?context=update`), on the resource endpoints, the rows of a lens and the rows and records of the relationship endpoints, so a client that renders the schema's field list leaves those fields out instead of showing them empty. The `_pivot` values of a record a `BelongsToMany` / `MorphToMany` list attaches carry their own `_hidden`: the pivot fields hidden for that pivot row. See [Fields → Field authorization](https://getmartis.com/docs/core/fields#field-authorization-cansee-and-canseeformodel).

### Single record (CRUD)

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/martis/api/resources/{resource}/{id}` | Detail. |
| `POST` | `/martis/api/resources/{resource}` | Create. Use `multipart/form-data` for file uploads. |
| `PUT` | `/martis/api/resources/{resource}/{id}` | Update. |
| `DELETE` | `/martis/api/resources/{resource}/{id}` | Delete (archives soft-delete models). |
| `PUT` | `/martis/api/resources/{resource}/{id}/restore` | Restore soft-deleted record. |
| `DELETE` | `/martis/api/resources/{resource}/{id}/force` | Force-delete (only when `forceDelete` policy ability passes). |
| `POST` | `/martis/api/resources/{resource}/{id}/replicate` | Replicate the row (when `replicate` policy ability passes). |
| `POST` | `/martis/api/resources/{resource}/{id}/peek` | Lightweight detail snapshot for the BelongsTo / MorphTo peek popover. |

### Schema

```
GET /martis/api/resources/{resource}/schema
```

Returns the field structure and metadata for the resource — `fields`, `fieldsForIndex`, `fieldsForDetail`, `fieldsForCreate`, `fieldsForUpdate`, `accentColor`, `loaderConfig`, `tableStriped`, `perPageOptions`, `overrides`, etc. The React shell hits this endpoint on every navigation to a resource page.

Every field list leaves out a field the user cannot see (`canSee()`): the contextual arrays, and since v1.38.0 `fields` too (it listed every field of `fields()`), as well as the row fields of a Repeater's row types and the pivot fields of a `BelongsToMany` / `MorphToMany`. `fieldsForCreate` and `fieldsForInlineCreate` also leave out a field `canSeeForModel()` hides for the new model a create fills (v1.38.0), as does `inline-create-schema`; the other lists describe the resource, and each record names the fields it hides (`_hidden`, above).

### Inline create

```
POST /martis/api/resources/{resource}/inline-create-schema
POST /martis/api/resources/{resource}/inline-create
```

Drives the lightweight "Create related" form embedded in HasMany / BelongsToMany pickers without leaving the parent page.

### Slug live collision check

```
GET /martis/api/resources/{resource}/slug-check/{field}?value=...&id=...
```

Used by `Slug::make()` for live "this slug is taken" hints in the create / update form. `id` is the record being edited: when it names a record the user may update (`authorizedToUpdate()`), that record is left out of the uniqueness probe and the Slug is read from `fieldsForUpdate()`; otherwise (no `id`, no such record, or a record the user may not update) the Slug is read from `fieldsForCreate()`, then `fieldsForInlineCreate()`, and nothing is left out of the probe. `fields()` is searched last. A Slug the user cannot see (`canSee()`, or `canSeeForModel()` for the record the form edits or the new one a create fills) answers 404 like an undeclared one (v1.38.0).

### Select option search

```
GET /martis/api/resources/{resource}/fields/{field}/options?search=term&context=create|update&id=<record>
```

Backs `Select::searchOptionsUsing()` (v1.37.0). Locates the select in the field set of the given context (default `create`: `fieldsForCreate()`, then `fieldsForInlineCreate()` since v1.38.0; `update`: `fieldsForUpdate()`), gated on the matching ability like `sync-field` (`create`, or `update` with the record named by `id` bound first: `id` is required in the update context, 404 when it does not exist), and returns `{ options: [{ label, value, group? }] }` (`group` for grouped options, v2.0.0). 422 for an unknown field, a non-select field or a select without a server-side resolver, and for a select the user cannot see, which answers exactly like an unknown field (`canSee()`, or `canSeeForModel()` for the record the form edits or the new one a create fills; v1.38.0). A select in a Repeater row adds `&repeater={attribute}&repeatable={type}` and is found in that row type's `fields()` (v1.38.0, see [Repeater → Relation pickers and remote selects in rows](https://getmartis.com/docs/core/repeater#relation-pickers-and-remote-selects-in-rows)).

### Lenses

```
GET /martis/api/resources/{resource}/lenses/{lens}
```

Index endpoint for the named lens. Same query params as the resource index. `sort` names a sortable field of the lens's own `fields()` the user can see; any other column is dropped before the lens runs, so its default ordering applies (v1.38.0: the lens was ordered by any column `sort` named, and a column that does not exist answered 500 on MySQL / PostgreSQL). See [Lenses → Which columns sort a lens](https://getmartis.com/docs/core/lenses#which-columns-sort-a-lens).

### Reactive (dependsOn) field sync

```
POST /martis/api/resources/{resource}/sync-field
Body: { field: "<attribute>", formData: { ...current form values... }, context: "create" | "update", id?: <record> }
```

Server-side resolution of reactive `dependsOn()` fields. Frontend debounces (200 ms) + uses `AbortController` so the latest value always wins. Gated on the create ability, or on the update ability with the record named by `id` bound first (required in the update context since v1.37.0, so a policy typed `update(User, Model)` receives the model; 404 when the record does not exist). Rejects unknown attributes (422), non-reactive attributes (422), empty attribute names (422) and a `field` that is not a string (422; v1.38.0: 500); a field the user cannot see answers exactly like an unknown attribute (v1.38.0). The field is looked up in the same field set as the select search above (`create` covers `fieldsForInlineCreate()` since v1.38.0). See [Fields § Reactive fields](https://getmartis.com/docs/core/fields#reactive-fields--dependsonfield-closure).

## Relationship Endpoints

### BelongsTo options (relatable)

```
GET /martis/api/resources/{resource}/{id}/relatable/{field}
GET /martis/api/resources/{resource}/{id}/relatable/{field}?search=term
```

Returns the option list for a BelongsTo / MorphTo / Tag picker, filtered by the resource's `relatableQuery()` if defined. Each row is `id`, `_title` and the attributes the picker reads (the field's title attribute and, with subtitles, its subtitle attribute, when they are index fields the user may see), not the full index row (v2.4.0). The context-free form `GET /martis/api/resources/_/_/relatable/{field}?related_resource={uriKey}` has no field to read them from: it answers only `id`, `_title` and, when `title_attribute` equals the related resource's declared `titleAttribute()`, that visible index field (`subtitle_attribute`, and any other column a client names, is ignored); it applies the related resource's `relatableQuery()` alone (see [Resources → relatableQuery()](https://getmartis.com/docs/core/resources#relatablequery)). The field is looked up on the form the picker renders in: `fieldsForUpdate()` (on the resource bound to the record) when `{id}` names a record the user may update (`authorizedToUpdate()`), otherwise `fieldsForCreate()` then `fieldsForInlineCreate()` (`{id}` = `_` on a create form; a record the user may not update is answered like a missing one); `fields()` comes last. A picker declared on one form only resolves (v1.38.0). A create form nested in another resource's page (the inline-create modal) sends `_`, the pickers of an action modal use the action's own endpoint (see [Actions](https://getmartis.com/docs/reference/api#actions)), and the pickers among a relationship's pivot fields the panel's (below). A picker in a Repeater row adds `&repeater={attribute}&repeatable={type}` to any of them and is read from that row type's `fields()` (v1.38.0). On every one of them a picker the user cannot see answers 404 exactly like an undeclared one (v1.38.0): its field's `canSee()`, or `canSeeForModel()` for the record the form edits (the new one a create fills, the pivot row for a pivot field), a row field's `canSee()` and the `canSee()` of the Repeater holding the row. See [Relationships → Relation fields declared on one form only](https://getmartis.com/docs/core/relationships#relation-fields-declared-on-one-form-only).

### HasMany / HasOne / BelongsToMany / MorphMany / MorphOne / MorphToMany

Each relation type has a full sub-tree under the parent's URL. The shape mirrors the parent's CRUD verbs:

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/{r}/{id}/has-many/{rel}` | List related records (paginated, searchable, sortable). |
| `POST` | `/{r}/{id}/has-many/{rel}` | Create a child. |
| `PUT` | `/{r}/{id}/has-many/{rel}/{relatedId}` | Update a child. |
| `DELETE` | `/{r}/{id}/has-many/{rel}/{relatedId}` | Delete a child. |
| `GET` | `/{r}/{id}/has-one/{rel}` | Show. |
| `POST` | `/{r}/{id}/has-one/{rel}` | Create. |
| `PUT` | `/{r}/{id}/has-one/{rel}?relatedId={id}` | Update the record the card shows. `relatedId` (required, v2.0.0) is the id the client read from the `GET`: `422` without it, `409` when the relationship holds another record by then (see [Conflict (409)](https://getmartis.com/docs/reference/api#conflict-409)). A stale id answers `409` before the policy (the policy is the current record's); the policy's `403` comes before the `422` of a missing id. |
| `DELETE` | `/{r}/{id}/has-one/{rel}?relatedId={id}` | Delete the record the card shows, under the same rules. |
| `GET` | `/{r}/{id}/belongs-to-many/{rel}` | List with pivot data. |
| `GET` | `/{r}/{id}/belongs-to-many/{rel}/attachable` | Options available to attach. `meta.hiddenPivotFields` lists the attributes of the pivot fields a new row hides (`canSeeForModel()` on the row the attach writes), which the attach modal leaves out (v1.38.0). |
| `POST` | `/{r}/{id}/belongs-to-many/{rel}/attach` | Attach with optional pivot fields. |
| `DELETE` | `/{r}/{id}/belongs-to-many/{rel}/{relatedId}/detach` | Detach. |
| `PUT` | `/{r}/{id}/belongs-to-many/{rel}/{relatedId}/pivot` | Update pivot row. |
| `GET` | `/{r}/{id}/belongs-to-many/{rel}/pivot-fields/relatable/{field}` | Options of a `BelongsTo` / `MorphTo` / `Tag` pivot field in the attach modal: read from the relationship's `fields()`, gated like the panel and on `attachAny{Model}`, with the parent resource as the source of the relatable hooks (v1.38.0). |
| `GET` | `/{r}/{id}/belongs-to-many/{rel}/pivot-fields/{relatedId}/relatable/{field}` | The same in the pivot edit modal of an attached record, gated on its `updatePivot{Model}` (v1.38.0). |

(`/{r}` is shorthand for `/martis/api/resources/{resource}`.) MorphMany / MorphOne / MorphToMany follow the same shape under `/morph-many/`, `/morph-one/`, `/morph-to-many/`. The `morph-one` `PUT` and `DELETE` take the same required `?relatedId=`.

The lists (`GET` on `has-many`, `morph-many`, `belongs-to-many` and `morph-to-many`) take the `search`, `sort`, `direction` and `per_page` parameters of the resource index, applied with the related resource's fields: `sort` names a `sortable()` field of the related resource the user can see, and anything else is ignored (v1.38.0).

A relationship field that `canSeeForModel()` hides for the parent record answers 404 on all of them, as an undeclared relationship (v1.38.0). Every record they send leaves out the fields hidden for it, and their writes neither validate nor write those fields: the new model decides on a create, the pivot row on an attach and a pivot update (v1.38.0). See [Fields → Field authorization](https://getmartis.com/docs/core/fields#field-authorization-cansee-and-canseeformodel).

## Actions

Per-resource and per-row action execution.

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/martis/api/resources/{resource}/actions` | List actions visible on the index. |
| `GET` | `/martis/api/resources/{resource}/actions/{action}/fields` | Confirmation-modal field schema for an action, without the fields the user cannot see (v1.38.0). |
| `GET` | `/martis/api/resources/{resource}/actions/{action}/relatable/{field}` | Options of a `BelongsTo` / `MorphTo` / `Tag` the action declares, read from the action's `fields()` (v1.38.0). Gated on `viewAny` of the resource, the action's `canSee()` and `viewAny` of the related resource. |
| `POST` | `/martis/api/resources/{resource}/actions/{action}` | Run a bulk / standalone action. |
| `POST` | `/martis/api/resources/{resource}/{id}/actions/{action}` | Run an inline (per-row) action. |
| `GET` | `/martis/api/resources/{resource}/lenses/{lens}/actions` | The actions a lens runs: its own `actions()`, or the resource's when the lens does not override it (v2.0.1). 403 on a lens the user cannot see (`canSee()`), 404 on an unknown one. |
| `GET` | `/martis/api/resources/{resource}/lenses/{lens}/actions/{action}/fields` | Field schema of an action the lens runs (v2.0.1). |
| `GET` | `/martis/api/resources/{resource}/lenses/{lens}/actions/{action}/relatable/{field}` | Picker options of a relation field of an action the lens runs, gated as the resource route plus the lens's `canSee()` (v2.0.1). |
| `POST` | `/martis/api/resources/{resource}/lenses/{lens}/actions/{action}` | Run a bulk, inline or standalone action of the lens (v2.0.1), as Nova's lens action route. The selected records are the ones the lens's `query()` lists (with the lens filters of `?filters=`); an id it does not list does not resolve (404 when none does). The action's `canSee()` / `canRun()` and the run-action policy apply. |
| `GET` | `/martis/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{rel}/actions` | Pivot-row actions list. |
| `GET` | `/martis/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{rel}/actions/{action}/fields` | Field schema of a pivot action, without the fields the user cannot see (v1.38.0). |
| `GET` | `/martis/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{rel}/actions/{action}/relatable/{field}` | Options of a `BelongsTo` / `MorphTo` / `Tag` a pivot action declares, behind the panel's pivot action gates, with the parent resource as the source of the relatable hooks (v1.38.0). |
| `POST` | `/martis/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{rel}/actions/{action}` | Run a pivot-row action: a dry run (`dryRun: true` with `withDryRun()`) answers `{ preview }`, a `ShouldQueue` action is queued, and the run is written to the action event log (v1.38.0). Gated on the parent's `runAction` / `runDestructiveAction` policy ability like a resource action. |

A run validates, and takes from `fields`, only the values of the fields the user may set: a field the user cannot see, a readonly one and a computed one are not validated, and `handle()` receives their `default()` or nothing (v1.38.0). See [Actions → Fields the request cannot set](https://getmartis.com/docs/core/actions#fields-the-request-cannot-set). The pickers of an Action modal answer 404 for a field the user cannot see, like an undeclared one (v1.38.0).

## Translation Endpoint

```
GET /martis/api/translations/{locale}
```

**Public** (no auth required). Bundled locales: `en`, `pt_BR`, `pt_PT` (underscore form is canonical; the controller normalises hyphenated input). See [i18n](https://getmartis.com/docs/customization/i18n) for the merge order, `app_namespaces`, and fallback chain.

## Navigation Endpoints

```
GET /martis/api/navigation
GET /martis/api/navigation/badges    # v1.8.8
```

`/navigation` returns the canonical sidebar tree the React shell renders — sections with label, icon, and items (resources, links, dashboards, tools). It is fetched once per session + on route mutations and is **not auto-polled**.

`/navigation/badges` returns a flat `{ "resource:users": 1284, "tool:standards": 7 }` map keyed by `"{type}:{uriKey}"`. The SPA polls this endpoint at the cadence configured in `martis.navigation.badges_poll_interval` (default 300 000 ms = 5 min) and merges the values into the cached tree. 5–10× cheaper server-side than the full tree; resources that opt out of `showMenuCount()` are excluded, and counters whose `menuCount()` throws are skipped and reported (v1.32.4). With dev tools on, the failed counters are listed under a reserved `_failed` key. See [Menus → Badges-Only API](https://getmartis.com/docs/core/menus#badges-only-api-v188).

## Global Search

```
GET /martis/api/search?q=...
```

Cross-resource record search. Powers the topbar search input. Each resource is matched on the `searchable()` fields the user can see (v1.38.0), among the records its index lists: its `scopes()`, then `indexQuery()` (v2.0). See [Global Search](https://getmartis.com/docs/core/global-search).

## Command Palette

```
GET /martis/api/command-palette?q=...
```

Aggregates the four ⌘K palette sections (Resources / Actions / Recent / Records) into a single payload. Short client-side cache (30 s) on top.

## Dashboards

```
GET  /martis/api/dashboards                              List visible dashboards.
GET  /martis/api/dashboards/{uriKey}                     Single dashboard descriptor + cards.
GET  /martis/api/dashboards/{uriKey}/cards/{card}        Compute a single metric card.
```

A dashboard soft-locked for the user answers `200 { "locked": true, "lock": {...} }` on its page endpoint (no cards, no filters) and `403` with the lock on the card endpoint (see [Locked (403)](https://getmartis.com/docs/reference/api#locked-403)); so does a locked card.

The single dashboard endpoint returns the layout type (`cards` or `default`), the list of metric cards, dashboard-level filters, and any `withMeta()` data set on the PHP class.

The card endpoint takes the active filters as `?filters=` (the JSON object `{"<filter uriKey>": <value>}`), the parameter the dashboard page keeps in its own address since v2.9.0 ([Filters in the URL](https://getmartis.com/docs/core/dashboards#filters-in-the-url-v290)).

## Tools

Surface for the [Custom Tools](https://getmartis.com/docs/customization/tools) primitive.

```
GET  /martis/api/tools                  List every authorised tool.
GET  /martis/api/tools/{uriKey}         Single tool metadata, or 404 (also when canSee denies).
GET  /martis/api/tools/{uriKey}/fields  Serialized field definitions of a Tool implementing ProvidesFields,
                                        without the fields the user cannot see (canSee(); v1.38.0).
GET  /martis/api/tools/{uriKey}/fields/{field}/options?search=
                                        Server-side option search for a Tool select (v1.37.0); 422 when the field has no resolver.
                                        A select in a Repeater row adds &repeater=&repeatable= (v1.38.0).
```

The 404-when-denied behaviour is intentional: an unauthorised user cannot probe which tools the app ships. The routes a tool adds under `/martis/api/tools/{uriKey}/...` (`/{martis.path}/...`) with `loadRoutes()` or `ToolRoutes::middleware()` run the same middleware as these endpoints and answer that user the same 404 (v2.0; see [Tools → Tool routes and their middleware](https://getmartis.com/docs/customization/tools#tool-routes-and-their-middleware)).

## Preferences

Per-user theme / accent / density / locale / reduced-motion persistence.

```
GET     /martis/api/preferences         Current user's preferences payload + meta
PUT     /martis/api/preferences         Persist a partial update
DELETE  /martis/api/preferences         Reset to defaults
```

PUT body is partial — pass only the keys you want to change. Returns the full snapshot so the React shell can re-bootstrap. See [User Preferences](https://getmartis.com/docs/reference/preferences).

## Notifications

Drive the topbar bell dropdown over Laravel's standard `notifications` table.

```
GET     /martis/api/notifications                 Paginated list (capped at 50 server-side).
GET     /martis/api/notifications/unread-count    Just the unread count (used for badge polling).
POST    /martis/api/notifications/read-all        Mark every notification as read.
DELETE  /martis/api/notifications                 Clear every notification.
POST    /martis/api/notifications/{id}/read       Mark a single notification as read.
DELETE  /martis/api/notifications/{id}            Delete a single notification.
```

All endpoints scope to the authenticated user. Cross-user access returns 404. See [Notifications](https://getmartis.com/docs/reference/notifications).

## Cache Admin

Per-subsystem cache toggle + clear. Gated by the `manage-martis-cache` ability — define it in your `AuthServiceProvider`.

```
GET   /martis/api/cache                  Snapshot: per-type effective state, TTLs, versions.
POST  /martis/api/cache/clear            Clear one type (?type=metrics) or all.
POST  /martis/api/cache/disable          Toggle a runtime override OFF.
POST  /martis/api/cache/enable           Toggle a runtime override ON.
POST  /martis/api/cache/reset-override   Drop the runtime override; falls back to config.
```

See [Cache Control Surface](https://getmartis.com/docs/reference/cache).

## Profile

User profile + avatar + 2FA + browser sessions (when `martis.profile.enabled`). The avatar routes are registered only while `profile.avatar.enabled` is on, the 2FA routes only while `profile.two_factor.enabled` is on, and with `profile.account.email_editable` off a new email answers `422` (v2.8.0; see [Configuration → Profile](https://getmartis.com/docs/reference/configuration#profile)).

```
GET     /martis/api/profile                   Current user payload (name, email, avatar, 2fa state)
PATCH   /martis/api/profile                   Update name / email (a new email needs current_password and a confirmation link)
POST    /martis/api/profile/password          Change password (requires current_password)
POST    /martis/api/profile/avatar            Upload avatar (multipart)
DELETE  /martis/api/profile/avatar            Remove avatar
POST    /martis/api/profile/2fa/setup         Initialize 2FA (returns QR code SVG + secret)
POST    /martis/api/profile/2fa/confirm       Verify the OTP code and activate 2FA
DELETE  /martis/api/profile/2fa               Disable 2FA
POST    /martis/api/profile/2fa/recovery-codes  Regenerate the recovery-code set (needs current_password, v2.4.0; 403 while impersonating)
GET     /martis/api/profile/sessions                v1.8.8 — list active sessions for the current user
DELETE  /martis/api/profile/sessions/others        v1.8.8 — revoke every session except the current one
DELETE  /martis/api/profile/sessions/{id}          v1.8.8 — revoke a single session by the opaque `id` of the list (v2.4.0; the current session is a no-op)
```

See [Authentication § Browser sessions](https://getmartis.com/docs/auth/authentication#browser-sessions).

## Impersonation

Two-layer guard (master switch + `martis-impersonate` Gate). Default master switch is OFF — endpoints return 503 until enabled.

```
GET   /martis/api/impersonation/status                   Snapshot — { active, enabled, original, target, started_at }
POST  /martis/api/impersonation/start/{userId}           Start (200 / 503 / 403 / 404 / 422)
POST  /martis/api/impersonation/stop                     Stop, restore operator (idempotent)
```

Error matrix:

| HTTP | Cause |
| --- | --- |
| 503 | Master switch off. |
| 403 | `martis-impersonate` Gate returned false for this target (it receives the operator and the target, v2.4.0), or the operator model's `canImpersonate()` hook returned false. A missing target id answers 403 too when the gate refuses the operator. |
| 404 | Target user id does not exist on the configured guard's user provider. |
| 422 | Self-impersonation OR impersonation already active OR target implements `Martis\Contracts\NotImpersonable` (v1.8.8) OR target's `canBeImpersonated()` hook returned false (v2.4.0). |
| 200 | Started — body is the active snapshot. |

See [Impersonation](https://getmartis.com/docs/auth/impersonation).

## Meta

Read-only descriptors for the React layer (auth guard names, etc.).

```
GET  /martis/api/_meta/guards   List of `array_keys(config('auth.guards'))` for the GuardSelect field
```

## Attachments

```
POST  /martis/api/attachments/upload?resource=&field=[&id=][&repeater=&repeatable=]   Upload an inline asset (used by the Trix and Markdown fields)
```

Returns the URL the editor inserts inline. The upload names the resource, the field and (editing) the record, is authorised like the form it comes from (403 without the create or update ability, 404 for a field the form does not have, 403 for one without `withFiles()`), goes to the disk the field declares, and is throttled per user (429). See [Resources → Attachment Uploads](https://getmartis.com/docs/core/resources#attachment-uploads-trix--markdown) and [Fields § Trix](https://getmartis.com/docs/core/fields).

## Error Responses

### Validation Error (422)

The resource, relationship, pivot and Action endpoints answer a failed validation with the Martis envelope: `errors` is a list with one entry per message, and `field` is the path of the value that failed.

```
{
  "message": "The given data was invalid.",
  "errors": [
    { "field": "title", "message": "The Title field is required.", "code": "required" },
    { "field": "sections.1.fields.key", "message": "The Key field is required.", "code": "required" }
  ]
}
```

`field` is the field's attribute for the field's own error, and the dotted path of the value for an error inside a field's value: `sections.1.fields.key` is the `key` field of row 1 of the `sections` Repeater (see [Repeater § Validation](https://getmartis.com/docs/core/repeater#validation)). `code` is a best-effort hint derived from the message (`required`, `unique`, `email`, `min`, `max`, otherwise `invalid`). The top-level `message` is the resource's `validationMessage()` on the resource endpoints, "Validation failed." on the relationship and pivot endpoints, and "The given data was invalid." on the Action endpoints.

A write the database refuses with a unique index (a race past a field's `unique()` rule, or an index the rules do not mirror) answers the same `422` on the resource, relationship and pivot endpoints (the relationship and pivot ones since v2.9.0): `message` is "A record with this value already exists. Please use a unique value.", with one error per written field whose column the index covers, worded as the field's `unique()` rule would, and no error when the index names none. Any other database error answers `500` with a generic message and no SQL. See [When the database index refuses the write](https://getmartis.com/docs/core/fields#when-the-database-index-refuses-the-write-v290).

The endpoints that validate with Laravel's `$request->validate()` (login, registration, password reset, profile, two-factor, magic link) answer with Laravel's own shape instead, a map of messages per field:

```
{
  "message": "The email field is required.",
  "errors": {
    "email": ["The email field is required."]
  }
}
```

### Unauthorized (401)

```
{ "message": "Unauthenticated." }
```

### Not Found (404)

```
{ "message": "Resource not found." }
```

### Forbidden (403)

```
{ "message": "This action is unauthorized.", "errors": [] }
```

The `errors` array is intentionally empty so the SPA can route the same envelope through its generic 422-style error renderer; consumer overrides can populate it for richer messaging.

### Locked (403)

```
{
    "message": "This feature is locked for your account.",
    "errors": [],
    "locked": true,
    "lock": { "reason": "plan:pro", "modal": { "title": "This is a Pro feature", "message": "..." } }
}
```

Every endpoint that serves the data of an entity soft-locked for the user (`lockedFor()`, `requirePlan()`) answers it, a resource's records, a lens, a card, a tool's fields and routes included; only the page endpoints of a dashboard and a tool answer `200 { "locked": true, "lock": {...} }`. The message is translated (`martis::messages.feature_locked`). See [Soft-gates → What a lock stops on the server](https://getmartis.com/docs/core/gates#what-a-lock-stops-on-the-server) (v2.4.0).

### Conflict (409)

```
{ "message": "The record changed since the card loaded; reload to see it.", "errors": [] }
```

A `PUT` or `DELETE` on a one-record card (`…/has-one/{rel}`, `…/morph-one/{rel}`) whose `?relatedId=` is not the record the relationship holds now: a newer one-of-many record, a replaced `HasOne` / `MorphOne`, or another record through a `HasOneThrough` (the shown one or its intermediate record trashed, for instance). Nothing is written; reload the card (its `GET`) and retry on the record it returns. The message is translated (`martis::messages.card_record_changed`).

### Service Unavailable (503)

```
{ "message": "Impersonation is disabled. Set `martis.impersonation.enabled` to true." }
```

Returned by surfaces gated behind a master switch (impersonation, optionally magic-link / forgot-password / email verification).

Authorization

Configuration
