Layer 4: Public API
A single HTTP service. Every user action maps to one endpoint here, and every endpoint calls into one or more L3 workflows — no endpoint reaches L2 directly. Per-domain microservices and separate load balancers belong to a later stage.
Principles
- REST over HTTP/JSON. No GraphQL, no RPC. Resource-oriented URLs, standard methods (
GET,POST,PATCH,DELETE); noPUT. Composite reads (for example, the Home dashboard bundle) get their own dedicated endpoint rather than being assembled client-side. - One endpoint per workflow by default. Each L3
W###workflow maps to exactly oneE###endpoint. Departures from 1:1 are listed on the per-domain pages. - Zod is the source of truth; OpenAPI is emitted. Endpoints are authored as Zod schemas in
@pharus/api-contracts. The OpenAPI 3.1 document is generated from those schemas at build time using@asteasolutions/zod-to-openapiand checked into the repo so PRs review API changes as spec diffs. Agent-first usage stays satisfied — the emitted spec is the machine-readable contract — without paying the cost of hand-authoring YAML alongside Zod. The markdown files in this tree document the spec; the emitted OpenAPI document is the canonical machine-readable artifact. - Unversioned in v1.0.0. Endpoints live under
/api/...with no version prefix. A/v1prefix gets retrofitted when external clients or third-party integrations require it. No header-based or query-based versioning. - Auth carries over from today.
Authorization: Bearer <firebase-id-token>plusX-Org-Idresolve toreq.authUserandreq.orgIdbefore any handler runs. The middleware is unchanged; L4 just documents the contract. - Authorization at L4. The minimum access level (
member/admin/owner) sits on the endpoint and is enforced before the handler calls L3. L3 stays role-agnostic. Public token-gated endpoints (accept invitation, look up invitation by token) are marked explicitly. - Typed error envelope. Non-2xx responses use
{ error: { code, message, details? } }wherecodeis a stable machine identifier (not_found,in_use,invalid_transition,validation_failed,forbidden,unauthorized,conflict,internal_error). L3 typed errors (NotFoundError,InUseError,InvalidTransitionError,ValidationError) map deterministically to HTTP status +code. Tracks #362. - Deletes always return 200. Soft deletes (SKU, vendor, material — flip
isActiveto false) return the updated entity. Hard deletes return{ deletedId: "uuid" }as an echo. No204responses. - Pagination via cursor. Read endpoints that can return unbounded sets accept
?cursor=<opaque>&limit=<int>and return{ data: [...], nextCursor: string | null }. Bounded lookups (status types, channels, segments, brokers, locations) return the full list without pagination. - Request validation at the boundary. L4 parses the request with the OpenAPI-derived schema before calling L3. L3 receives already-typed inputs and never re-validates shape — only business rules.
- No idempotency keys in v1.0.0. Matches the L3 stance (#363). Bulk imports and receipt creation are the likely first adopters; they land when the symptom does.
- Bulk-import shape. Bulk-import endpoints (
POST /purchase-orders/bulk-import,/work-orders/bulk-import, etc.) accept a JSON object withrows: []and return per-row{ index, status, id? | error? }. No partial-failure rollback at the HTTP layer; L3 decides per workflow whether to commit per-row or all-or-nothing. - File uploads via multipart. The document upload endpoint (flat Documents domain) is the only true file upload in v1.0.0 and uses
multipart/form-data. The response is JSON metadata; the file lands in GCS via L3. Logo handling on E004 is not an upload — the endpoint takes alogoUrlstring (the frontend uploads via its storage of choice and PATCHes the URL). - PDF generation returns binary.
GETendpoints that produce a PDF (PO PDF generation, future invoice PDFs) returnapplication/pdfdirectly withContent-Disposition: attachment. They are not paginated and never include a JSON envelope. E116 soft-renders with placeholders when org branding is incomplete and setsX-Pharus-Warning: branding_incomplete.
Endpoints
Every endpoint has a globally unique E### ID assigned in canonical sidebar order, mirroring the L3 numbering so position is portable between L3 and L4 docs.
See the All endpoints page for the full flat table, or jump into a domain dropdown in the sidebar.
| Domain | Range |
|---|---|
| Platform | E001–E023 |
| Purchase Orders | E111–E128 |
| Work Orders | E129–E143 |
| Sales Orders | E144–E164 |
| Materials Inventory | E097–E103 |
| Finished Goods Inventory | E104–E110 |
| Customers | E044–E064 |
| Products | E076–E086 |
| Bill of Materials | E087–E091 |
| Vendors | E024–E029 |
| Materials | E065–E075 |
| Locations | E092–E096 |
| Contacts | E030–E043 |
Toolchain
| Concern | Pick |
|---|---|
| Spec version | OpenAPI 3.1 (JSON-Schema 2020-12 aligned, matches Zod output natively) |
| Authoring surface | Zod 4.x schemas in @pharus/api-contracts |
| Spec emitter | @asteasolutions/zod-to-openapi — framework-agnostic, runs at backend build time |
| Spec artifact | openapi.json checked into the repo, regenerated on every build |
| Frontend client + hooks | orval — reads openapi.json, emits typed fetch client + TanStack Query hooks + Zod runtime response validators |
| Frontend data layer | TanStack Query |
| CI drift check | Regenerate openapi.json and fail on git diff --exit-status |
The @pharus/api-contracts package exposes the Zod schemas (canonical), the emitted openapi.json, and re-exported TypeScript types. Backend handlers import the Zod schemas directly for request validation; the frontend imports the orval-generated hooks. The same emitted spec is the artifact that feeds Redoc / Swagger UI and any non-TS SDK generation when the public API surface opens up post-v1.0.0.
Also here
- User Actions — the pre-API action catalog from Task 2. Source of truth for everything below it.