Documentation menu
HomeDocsReferenceREST 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 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

ItemValue
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
FormatJSON
AuthSession 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 for the full surface.

Login

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

Responses:

StatusBodyDescription
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

MethodPathPurpose
POST/martis/api/auth/logoutInvalidate the server-side session.
GET/martis/api/auth/userPublic. 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/registerSelf-service registration when auth.registration.enabled=true.
POST/martis/api/auth/password/emailSend password-reset link. Gated on auth.passwordReset.enabled.
POST/martis/api/auth/password/resetSubmit new password with token.
POST/martis/api/auth/email/verification-notificationResend the verification email (throttled 6,1).
GET/martis/email/verifyThemed 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/requestv1.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/consumev2.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/challengeSubmit 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}/redirectKick off the OAuth flow. Routes only registered when auth.sso.enabled.
GET/martis/sso/{provider}/callbackHandle the IdP callback.

Resource Endpoints

The list of registered resources lives in the Navigation endpoint (/api/navigation). Each entry carries the uriKey you use below.

Index (List Records)

GET /martis/api/resources/{resource}

Query Parameters:

ParameterExampleDescription
search?search=johnFull-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:descSort 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=descDirection 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=25Records per page
page?page=2Page number
trashed?trashed=onlyonly 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.

Single record (CRUD)

MethodPathNotes
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}/restoreRestore soft-deleted record.
DELETE/martis/api/resources/{resource}/{id}/forceForce-delete (only when forceDelete policy ability passes).
POST/martis/api/resources/{resource}/{id}/replicateReplicate the row (when replicate policy ability passes).
POST/martis/api/resources/{resource}/{id}/peekLightweight 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).

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).

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.

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.

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()). 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), 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.

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:

MethodPathPurpose
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)). 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}/attachableOptions 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}/attachAttach with optional pivot fields.
DELETE/{r}/{id}/belongs-to-many/{rel}/{relatedId}/detachDetach.
PUT/{r}/{id}/belongs-to-many/{rel}/{relatedId}/pivotUpdate 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.

Actions

Per-resource and per-row action execution.

MethodPathPurpose
GET/martis/api/resources/{resource}/actionsList actions visible on the index.
GET/martis/api/resources/{resource}/actions/{action}/fieldsConfirmation-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}/actionsThe 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}/fieldsField 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}/actionsPivot-row actions list.
GET/martis/api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{rel}/actions/{action}/fieldsField 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. 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 for the merge order, app_namespaces, and fallback chain.

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.

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.

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)); 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).

Tools

Surface for the Custom 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).

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.

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.

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.

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).

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.

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:

HTTPCause
503Master switch off.
403martis-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.
404Target user id does not exist on the configured guard's user provider.
422Self-impersonation OR impersonation already active OR target implements Martis\Contracts\NotImpersonable (v1.8.8) OR target's canBeImpersonated() hook returned false (v2.4.0).
200Started — body is the active snapshot.

See 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 and Fields § Trix.

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). 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.

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 (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).