Skip to main content

API Conventions

What this covers / who it's for. The contract every /api/v1 surface honors: the JSON:API envelope and error vocabulary, URL versioning, the bigint-internal/UUIDv7-public id rule, the two idempotency layers, edge protection, and how the OpenAPI docs stay true. For the concrete builder classes and controller recipes, defer to the skill reference.

One envelope, everywhere

Every API response — client SPA (/api/v1/*) and operator backoffice (/api/v1/backoffice/*) alike — is a JSON:API document (application/vnd.api+json) built by one of exactly three Foundation builders: JsonApiResource (single entity), JsonApiCollection (lists, with JSON:API-shaped pagination for length-aware, simple, and cursor paginators), or JsonApiEnvelope (non-entity payloads). All three attach the same meta block — request_id, correlation_id, timestamp — stamped by the correlation middleware (AssignCorrelationId) into Context, so the response meta, the response headers, the bus audit lines, and every log record join on the same ids. Controllers never hand-roll JSON; they validate, dispatch through the bus, and wrap the returned DTO (see cqrs-bus.md).

The error contract

Errors are exceptions, rendered centrally by JsonApiExceptionHandler into { errors: [...], meta }. The stable machine surface is the ErrorCode vocabulary (Modules\Foundation\Enums\ErrorCode, a string-backed enum of 70+ codes — STEP_UP_REQUIRED, ACCOUNT_FROZEN, RATIONALE_REQUIRED, VALIDATION_ERROR, …): clients branch on errors[].code, never on prose. Domain rules throw Modules\Foundation\Exceptions\DomainException, which carries the ErrorCode plus optional JSON:API source (e.g. a pointer into the request document) and meta members that the handler renders onto the error object (D75). Validation errors keep the source.pointer = /data/attributes/<field> shape; unexpected throwables render a generic 500 with the real message leaked only in debug mode.

Versioning and route shape

Versioning is URL-based: everything lives under /api/v1, and a breaking change means a new api/v2 prefix group — v1 is never broken in place. Routes are owned by modules (auto-loaded by internachi/modular) in three flavors: central groups (api.v1. — discovery, registration), tenant groups (tenant.api.v1. — Sanctum stateful + X-Tenant tenancy stack), and backoffice groups (backoffice.api.v1. — Sanctum stateful + auth:web, no tenancy). The middleware order within each is load-bearing — see architecture-flow.md §2 and auth-and-rbac.md.

Identifiers on the wire (D25)

The API speaks UUIDv7 only. Internally every entity keys on a bigint PK (fast joins, the RLS session variable); externally the DB-generated uuid column is the id — in URLs (route binding resolves on uuid), in JSON:API id, and in every *_id attribute a DTO exposes (a tenant_id field on the wire carries the tenant's uuid, mapped in the DTO's fromModel()). A bigint in an API payload is a bug the review must catch; the schema/model guards enforce the column side (see data-model.md).

Idempotency — two distinct layers (D28/D56/D74)

Retryable writes are protected twice, at different altitudes. The layers are complementary, not redundant — the HTTP layer dedupes client retries of an endpoint, the bus layer dedupes the domain operation itself (including webhook-driven and internal dispatch):

HTTP edge (D74)Bus (D28/D56)
Opt-inIdempotency-Key request header on routes carrying the idempotency-key middleware (EnforceClientIdempotency)the command class implements Idempotent
Storeclient_idempotency_keys — tenant-scoped (RLS), keyed (tenant, actor, key, route)command_dedup_records — keyed on the command's dedup identity
Replaysthe stored HTTP responsethe stored handler result
On failurethe claim is released (the client may retry)the transaction rollback removes the claim

Edge protection: throttles and Turnstile

Every route group declares a throttle (10,1 on the unauthenticated central endpoints, 120,1 on the tenant group, 60,1 on the backoffice group), and every unauthenticated, abuse-attractive endpoint — tenant discovery, workspace registration, both login routes — additionally carries the turnstile middleware (VerifyTurnstile, Cloudflare Turnstile): email-first discovery makes email enumeration inherent, so the flow rate-limits and bot-gates instead of pretending otherwise (D16/D18). Fine-grained auth limiters (login 5/min per email+IP, MFA challenge attempt caps) live in the auth backends — see auth-and-rbac.md.

OpenAPI that stays true (Scramble)

Docs are generated from the routes and code by dedoc/scramble, so the spec cannot drift from the middleware or the DTOs — but only if authors feed it. The app registers two APIs in FoundationServiceProvider::registerApiDocumentationSurfaces():

APIScopeUI / JSON
client/api/v1/* except backoffice/docs/api/client, /docs/api/client.json
backoffice/api/v1/backoffice/*/docs/api/backoffice, /docs/api/backoffice.json

Foundation's Scramble extensions make the CQRS indirection documentable (they infer CommandBus::dispatch()/QueryBus::ask() return types from the handler), render DTOs and the JSON:API envelope, and mirror the error contract. The author's share of the deal, per action: a PHPDoc summary, a controller #[Group], a #[DomainError] for every handler/gate-thrown code (Scramble only sees the controller, never the handler), and #[SupportsIdempotencyKey] on idempotent endpoints. Security documentation is derived from middleware (auth:* → cookie-secured), so getting the route group right is the security doc. Export with php artisan scramble:export / make openapi-export and skim the diff — an empty summary or a wrong error code is a doc bug. Full workflow: the scramble-development skill (skills package).

Going deeper


← Engineering wiki index