These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Backend — routing

routes/api.php is the single source of truth for the HTTP surface. Roughly 60 endpoints across 16 controllers, organized into five access groups. This page is the shape of how requests are routed; for the full endpoint enumeration, see reference/api/index.

File layout

Laravel’s standard routing files are present, but only one is meaningfully used:

routes/ ├── api.php # All API routes — this is the file you'll edit ├── web.php # Empty / minimal — no browser-facing UI ├── console.php # Artisan commands └── channels.php # Broadcasting (unused in production today)

Everything documented below lives in routes/api.php:1-209.

How routing is wired

Laravel 11’s bootstrap pattern means routes/api.php is loaded by bootstrap/app.php via withRouting(api: __DIR__.'/../routes/api.php'). The same bootstrap file registers the middleware aliases that the routes reference:

AliasClassPurpose
auditRecordAuditLogLogs the request + response to audit_logs
roleEnsureUserRoleGates by User.role (e.g., role:admin)
subscription_tierEnsureSubscriptionTierGates by User.subscription_tier (e.g., subscription_tier:premium)

The auth:api middleware is the standard Laravel JWT guard, configured in config/auth.php to use tymon/jwt-auth.

The five access groups

Routes fall into five groups distinguished by their middleware composition. The composition determines who can call them and what gets logged.

1. Public (no middleware)

Open to anyone. No JWT, no token check.

PatternExamples
Account creation + authPOST /register, POST /login, GET /register/{token}
Webhook receiversPOST /webhooks/paddle
Visitor-side ingestionPOST /analytics
Mapping (throttled)GET /mapping/autocomplete, GET /mapping/places (30 req/min global throttle)

The mapping/* endpoints are throttled because they proxy to third-party autocomplete providers (Google Places, etc.) and the rate limit stands in for per-tenant quotas.

2. Authenticated (auth:api + audit)

The bulk of the API. Every dashboard interaction lands here.

The composition order is important: auth:api runs first (JWT validation), then audit runs second (request logging). The audit middleware reads the X-Audit-* headers the frontend attaches and persists a row in audit_logs with the request body, response body, status, and duration.

ResourceEndpoints (count)
/locationsCRUD + image upload + bulk import (~8)
/findersCRUD + analytics + integrations (~8)
/maps, /sets, /tagsCRUD each (5 each)
/custom-fieldsCRUD + reorder (~6)
/processesList, show, events, stream, retry, cancel (~6)
/ai-location-importsBatch CRUD + apply (~6)
/ai-location-improvementsBatch CRUD + apply + finalize (~7)
/searchSingle endpoint (1)
/billingcheckout, portal, sync (3)
/users/meCurrent user (1)

Total: ~50 endpoints in this group. See reference/api/index for the full enumeration.

3. Workspace tier (auth:api + audit + subscription_tier:premium)

A single resource group that requires Premium tier. The middleware returns 403 if the authenticated user’s subscription_tier is not premium.

PatternEndpoints
/workspacesCRUD (5)

The frontend can render workspace UI for non-premium users (the routes exist), but every API call returns 403 — which the frontend translates into upgrade prompts.

🔒 Internal only: This is the only place subscription-tier gating is currently enforced server-side. If you ship a feature that should be Premium-only, gate it via subscription_tier:premium on the route group rather than relying on frontend-only checks.

4. Admin (auth:api + audit + role:admin, prefix /admin)

The admin console. Every route is prefixed /admin and requires the user’s role claim to be admin.

ResourceEndpoints
/admin/usersList, show, update, delete (4)
/admin/workspacesList, show, update, delete (4)
/admin/locations, /admin/finders, /admin/sets, /admin/tags, /admin/mapsCRUD each (4 each)
/admin/audit-logsList, show (read-only) (2)
/admin/ai-logsList, show (read-only) (2)

These routes exist for internal moderation and debugging. The frontend renders them under /app/admin/* — see nextjs/routing.

5. External / widget-facing (prefix /ext)

The route group consumed by the embeddable widget. Mixed auth model:

  • Public + token-keyed: GET /ext/finders/details/{key} — returns finder config; the {key} URL parameter is the public finder token
  • Authenticated: /ext/finders/details/{key}/info, POST /ext/finders/details/{key}/sync, DELETE /ext/finders/details/{key} — used by the dashboard for managing the CDN sync state

The split exists because the widget needs to fetch finder data without a JWT (it has no place to keep secrets), but management operations on that same finder data must be authenticated to prevent griefing.

Middleware composition rules

When you add a route, the right middleware composition usually follows a simple decision tree:

  1. Is this called from the dashboard with a JWT? → put it in the authenticated group (group 2)
  2. Does it require Premium tier? → put it in the Premium group (group 3)
  3. Is it admin-only? → put it in the admin group (group 4)
  4. Is it called by the widget without a JWT? → put it under /ext (group 5), authenticate via finder token in the URL
  5. Is it a webhook or visitor-side ingestion? → put it in the public group (group 1), think carefully about rate limits

⚠️ Warning: Don’t add a new public endpoint without considering throttling. Public endpoints without rate limits are a denial-of-service risk; the existing mapping/* endpoints use Laravel’s throttle:30,1 for this reason.

The audit middleware in detail

RecordAuditLog (in app/Http/Middleware/) is on every authenticated, mutating route. It reads four optional headers from the request:

  • X-Audit-Request-Id — client-generated correlation id
  • X-Audit-Action — semantic action name (e.g., finder.update)
  • X-Audit-Resource-Type — resource type (e.g., Finder)
  • X-Audit-Resource-Id — resource id (e.g., 42)

It then captures: user, workspace, request body, response body, status code, duration, and any error message. The result is one row in audit_logs per mutating request.

This pattern is client-set intentionally — the dashboard knows the user intent ("finder.update" is more meaningful than "PUT /finders/42"); the backend just records what it’s told. See decisions/audit-headers-on-mutations for the rationale.

🔴 [NEEDS CLARIFICATION: Retention policy for audit_logs. The table grows unbounded today; no cleanup job visible. Affects long-term storage planning.]

Workspace scoping

Most authenticated endpoints accept an optional workspace_id query parameter. The frontend appends it automatically when a workspace is active. Server-side, controllers use this parameter to scope queries:

// Pattern (illustrative — real code lives in each controller): $workspace = $request->query('workspace_id'); $query = Location::query()->whereHas('workspaces', fn($q) => $q->where('id', $workspace));

This is enforced per-controller, not via global scope or middleware. That’s a known wrinkle: if you add a new endpoint that returns workspace-scoped data, you need to remember to filter by workspace_id yourself. See how-to/add-api-endpoint-end-to-end for the recipe.

🔴 [NEEDS CLARIFICATION: Confirm whether there’s a global scope or trait that enforces workspace scoping automatically, or whether it’s truly per-controller. Affects security review of any new endpoint.]

Versioning

The API is unversioned. There is no /v1/, /v2/ prefix. Breaking changes need to be coordinated across the dashboard and the widget — see how-to/add-api-endpoint-end-to-end for the deploy ordering rules.

The /ext/finders/details/{key} endpoint serves the widget; changing its response shape would break in-flight visitor sessions until the widget redeploys and the CDN cache rolls. Treat /ext schema changes as more sensitive than dashboard-only ones.

Adding a new route

The mechanical steps:

  1. Open routes/api.php
  2. Find the right Route::middleware(...)->group(function () { ... }) block (one of the five groups above)
  3. Add Route::get/post/put/patch/delete('path', [Controller::class, 'method']);
  4. Implement the controller method
  5. After deploy, add the row to reference/api/index and create or update the per-resource page (e.g., reference/api/finders)

For the cross-layer recipe (route → controller → frontend module → component), see how-to/add-api-endpoint-end-to-end.

Common pitfalls

  • Forgetting audit on a mutation — the request succeeds but no audit row is written. Hard to notice; usually caught by missing rows in the admin audit log view.
  • Forgetting workspace scoping — the endpoint returns data from across workspaces. This is a security bug; review every new authenticated GET that returns a list.
  • Putting widget routes outside /ext — the widget should never need a JWT; if you find yourself wanting auth:api on a widget-consumed endpoint, consider whether the operation should actually be performed by the dashboard on the widget’s behalf instead.
  • Adding throttling only to public routes — authenticated endpoints can still be abused by a single rogue tenant. Heavy-write endpoints (CSV import, AI generation) should consider throttling beyond what the queue layer provides.

Where to next