API Path Conventions — Ghasi Melmastoon
Companion: NAMING.md · ERROR_CODES.md · CODING_STANDARDS.md · docs/05-api-design.md · DEFINITION_OF_DONE.md
This is the single, authoritative URL grammar for every HTTP route under ghasi-melmastoon — every microservice API, every BFF, every webhook, every sync endpoint. The OpenAPI diff gate enforces it. The code-reviewer subagent flags violations.
1. URL grammar
/api/v<MAJOR>/<resource-plural-kebab>[/<id>][/<sub-resource-plural-kebab>][/<id>][/<verb-kebab>]
| Token | Rule | Example |
|---|---|---|
/api | Required prefix for service APIs (direct microservice). | /api/v1/reservations |
/bff/<surface> | Required prefix for BFF routes; replaces /api. Surface ∈ consumer, tenant-booking, backoffice. | /bff/tenant-booking/v1/quotes |
/sync/v<MAJOR> | Required prefix for sync engine routes (desktop ↔ cloud). | /sync/v1/pull |
/webhooks/<vendor> | Required prefix for inbound webhooks (one per vendor). | /webhooks/stripe |
/health, /ready, /metrics | Reserved single-segment liveness/readiness/metrics endpoints. | /health |
v<MAJOR> | Major version, integer ≥ 1. Never v1.0, never v1beta. | v1, v2 |
<resource-plural-kebab> | Plural noun, kebab-case, lower-case. Match an entity in the owning service's DOMAIN_MODEL.md. | reservations, key-credentials, rate-plans, room-types |
<id> | ULID with the registered prefix from NAMING.md §ID prefixes. | rsv_01H..., key_01H... |
<verb-kebab> | Past or present-tense verb in kebab-case. Used only for non-CRUD actions on a resource (e.g., check-in, cancel, refund, revoke). | /reservations/{id}/check-in |
Constraints:
- Lowercase only. No camelCase, no snake_case, no UPPERCASE.
- No trailing slashes.
- No file extensions in the URL (
.json,.xmlforbidden — use theAcceptheader). - No query parameters used as routing (use a path segment).
- Maximum URL depth: 6 segments after the version prefix. If you need more, model it as a separate resource.
- IDs always carry the prefix; never bare ULIDs in URLs.
2. HTTP method semantics
| Method | Semantics | Idempotent | Body | Response |
|---|---|---|---|---|
GET /<resource> | List | Yes | None | 200 + cursor-paginated DTO list |
GET /<resource>/{id} | Read | Yes | None | 200 + DTO; 404 + canonical error if absent |
POST /<resource> | Create | Idempotent via header (see §6) | Zod-validated DTO | 201 + DTO; Location header to the new resource |
PUT /<resource>/{id} | Replace | Yes | Zod-validated DTO (full) | 200 + DTO; 404 + canonical error if absent |
PATCH /<resource>/{id} | Partial update | Idempotent via header | RFC 7396 merge-patch DTO | 200 + DTO |
DELETE /<resource>/{id} | Delete | Yes | None | 204; 404 + canonical error if absent |
POST /<resource>/{id}/<verb> | Domain action | Idempotent via header | Action-specific Zod DTO | 200 + DTO (or 202 if async) |
No GET with a body. No DELETE with a body. No verbs in GET URLs.
3. Resource-vs-action decision
A new endpoint is a resource if it represents a "thing" with a lifecycle (create / read / update / delete). It is an action if it represents an irreversible state transition on an existing resource.
| Looks like | Is | Path |
|---|---|---|
| Confirm a reservation | Action on reservation | POST /api/v1/reservations/{id}/confirm |
| Cancel a reservation | Action on reservation | POST /api/v1/reservations/{id}/cancel |
| Refund a payment | Action on payment | POST /api/v1/payments/{id}/refund |
| Issue a key credential | Resource (lifecycle) | POST /api/v1/key-credentials |
| Revoke a key credential | Action on key credential | POST /api/v1/key-credentials/{id}/revoke |
| Submit theme for review | Action on theme | POST /api/v1/themes/{id}/submit-for-review |
| Publish a theme | Action on theme | POST /api/v1/themes/{id}/publish |
| Search properties | Resource collection (the search hits) | GET /api/v1/searchable-documents?… (search-aggregation) |
When in doubt, prefer resource. Verb URLs proliferate fast and become hard to evolve.
4. Versioning rules
- Every route is versioned with
/v<MAJOR>from day one. - A non-breaking change (additive field, new optional field, new endpoint) ships in the same major version.
- A breaking change (removed field, type change, semantics change, narrowed enum) requires a new major version:
- Mount the new version side-by-side:
/api/v1/...and/api/v2/.... - Add
Deprecation: trueandSunset: <RFC 1123 date>headers to v1 responses. - Sunset window: ≥ 90 days for service↔service routes, ≥ 180 days for BFF↔frontend routes.
- Track deprecation in
services/<name>/API_CONTRACTS.mddeprecation table.
- Mount the new version side-by-side:
- The OpenAPI diff gate compares the committed
openapi.jsonagainst the previous commit. Breaking diffs without a major bump fail CI.
5. Pagination, filtering, sorting
| Query parameter | Type | Default | Notes |
|---|---|---|---|
cursor | opaque string (base64-encoded ULID + filter hash) | empty | Forward-only. No offset. Reverse pagination is a separate parameter before. |
limit | integer | 25 | Max 100. Beyond that, return 400 MELMASTOON.GENERAL.PAGINATION_LIMIT_EXCEEDED. |
sort | comma-separated field or -field | resource-defined | Allowed fields are documented in API_CONTRACTS.md. Reject unknown fields with 400. |
filter[<field>] | scalar or comma-list | none | One filter per field. Multi-value = OR. Multiple filters = AND. |
q | string | none | Free-text query (only for search endpoints). |
include | comma-list of related resources | none | Sparse hydration; documented per route. |
Response envelope for collections:
{
"data": [ { ... } ],
"page": {
"cursor": "eyJpZC…",
"next": "eyJpZC…",
"prev": null,
"limit": 25,
"hasMore": true
}
}
Single-resource responses are flat (no data envelope) so the OpenAPI types are clean: Reservation not { data: Reservation }.
6. Idempotency
Every write endpoint (POST, PUT, PATCH, DELETE) accepts Idempotency-Key as a request header.
- Format: 16–64 ASCII chars; ULID is recommended.
- Server stores
(tenant_id, route, idempotency_key)→ response for 24 h. - A duplicate request returns the stored response with the original status code, headers, and body.
- A duplicate with a different body returns 409
MELMASTOON.GENERAL.IDEMPOTENCY_CONFLICT. - The header is mandatory on the consumer-facing BFF + the cash-in-transit endpoints (payments, lock issuance, refunds). It is optional but recommended elsewhere.
- Outbox writes use the idempotency key as the message dedupe key downstream.
7. Tenant context
Every authenticated route requires:
Authorization: Bearer <jwt>— JWT carriestid(tenant ID),sub(user ID),mid(membership ID).X-Tenant-Id: tnt_<ulid>— explicit header.- The
TenantContextGuardcross-checksheader.X-Tenant-Id≡jwt.tid. Mismatch → 403MELMASTOON.SECURITY.TENANT_MISMATCH. - The platform-admin (
platform_adminrole) may passX-Acting-Tenant-Idto override; logged withactor_role=platform_adminfor audit.
Public, unauthenticated routes (consumer meta-search, public booking quote) accept neither header. They derive context from the URL (e.g., /bff/tenant-booking/v1/quotes resolves the tenant from the host or path).
8. Headers (standardized)
| Header | Direction | Purpose |
|---|---|---|
Authorization | Request | JWT bearer (see §7). |
X-Tenant-Id | Request | Explicit tenant context (see §7). |
X-Request-Id | Request, optional; mirrored in response | Correlation. Generated server-side if absent. |
Idempotency-Key | Request | See §6. |
traceparent, tracestate | Bidirectional | W3C Trace Context. Always propagated. |
Accept-Language | Request | RFC 5646 BCP-47 list; resolves locale via fallback chain in tenant.localeFallback. |
Accept | Request | application/json only. Other types → 406. |
Content-Type | Request (writes) | application/json or application/merge-patch+json (PATCH). |
If-Match | Request (PUT/PATCH) | ETag for optimistic concurrency. Mismatch → 412 MELMASTOON.GENERAL.PRECONDITION_FAILED. |
ETag | Response | Strong ETag based on aggregate version + content hash. |
Last-Modified | Response | RFC 1123 timestamp. |
Sunset, Deprecation | Response (deprecated routes) | RFC 8594 + draft Deprecation header. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | Response | RFC 9440. |
X-Service | Response | Owning service name (audit + debugging). |
X-API-Version | Response | Echoed major version for clients that didn't introspect the URL. |
9. Status codes
| Code | When |
|---|---|
200 OK | Successful read or update. |
201 Created | Successful create; Location header points to the new resource. |
202 Accepted | Async work queued; body includes a jobId and a Location for polling. |
204 No Content | Successful delete or action with no body. |
400 Bad Request | Validation failure; body is the canonical error envelope. |
401 Unauthorized | Missing or invalid JWT. |
403 Forbidden | Authenticated but not authorized for this tenant or this resource. |
404 Not Found | Resource does not exist (or does not exist for this tenant — RLS hides cross-tenant existence). |
409 Conflict | Idempotency conflict, optimistic-lock conflict, business-rule conflict (e.g., overbooking). |
410 Gone | Resource was deleted; do not retry. |
412 Precondition Failed | If-Match ETag mismatch. |
415 Unsupported Media Type | Wrong Content-Type. |
422 Unprocessable Entity | Validation passed but business rule rejected (use sparingly; prefer 409 for state conflicts). |
429 Too Many Requests | Rate-limit exceeded; RateLimit-* headers + Retry-After. |
500 Internal Server Error | Unexpected server failure; never exposes stack trace. |
502 Bad Gateway | Upstream third-party failed (lock vendor, payment vendor); body documents which one. |
503 Service Unavailable | Downstream dependency degraded; Retry-After set. |
504 Gateway Timeout | Upstream timeout. |
10. Error response envelope (always identical)
{
"error": {
"code": "MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED",
"message": "Cannot confirm reservation: no inventory hold is attached.",
"details": {
"reservationId": "rsv_01H...",
"roomTypeId": "rmt_01H..."
},
"requestId": "req_01H...",
"timestamp": "2026-04-23T10:11:12.345Z",
"documentation": "https://docs.ghasi.tech/errors/MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED"
}
}
code— canonical, machine-readable, registered in ERROR_CODES.md.message— human-readable English; localized client-side via the code → translation map.details— optional, schema-documented per code.requestId— always present, equal toX-Request-Idof the response.timestamp— ISO 8601 UTC.documentation— optional URL to public error docs (auto-generated from the registry).
Never include stack traces, raw SQL, raw third-party error payloads, PII, or secrets in details.
11. BFF route conventions
BFFs (bff-consumer-service, bff-tenant-booking-service, bff-backoffice-service) follow the same grammar with these additions:
- Prefix is
/bff/<surface>/v<MAJOR>instead of/api/v<MAJOR>.<surface>∈consumer,tenant-booking,backoffice. - BFF routes may compose multiple downstream service responses into a single screen-shaped DTO. Document the composition in
API_CONTRACTS.md. - BFF routes may be screen-scoped (
/bff/backoffice/v1/screens/arrivals-board) when the response is the data for one specific screen. This is allowed; document underAPI_CONTRACTS.md. - BFFs never expose internal service shapes. Always remap to
@ghasi/api-contractsBFF types. - BFFs always carry session cookies for browser surfaces; mobile uses
Authorizationbearer.
12. Sync engine routes
The desktop ↔ cloud sync engine has its own grammar:
POST /sync/v1/pull— body declarescursorper aggregate; response is a delta + new cursor + freshness watermark.POST /sync/v1/push— body is an outbox batch; response is per-recordaccepted/conflict/rejected.POST /sync/v1/handshake— device binds, exchanges keys, declares schema version.POST /sync/v1/heartbeat— keep-alive + clock skew measurement.
Detailed contract in services/<name>/SYNC_CONTRACT.md per service. The cross-service shape lives in @ghasi/sync-protocol.
13. Webhook conventions
Inbound webhooks (Stripe, PayPal, MFS providers, lock vendors):
- Path:
/webhooks/<vendor>. One path per vendor. - Signature verification mandatory, before any other parsing. Failure → 401.
- Idempotent processing. Vendor's event ID is the dedupe key.
- Always respond 200 quickly (≤ 500 ms) and process async.
- Vendor-specific request shapes are documented in
services/<name>/API_CONTRACTS.mdunder "Webhooks".
Outbound webhooks (we send to tenants): not in R1; will follow this same grammar when introduced.
14. Liveness, readiness, metrics
| Path | Purpose | Auth | Body |
|---|---|---|---|
/health | Liveness probe (process is up) | None | { "status": "ok" } |
/ready | Readiness probe (deps reachable: DB, Pub/Sub, downstream services) | None | { "status": "ok" or "degraded", "checks": [...] } |
/metrics | Prometheus scrape | Internal-only (network policy) | OpenMetrics text |
These three paths are the only single-segment routes allowed.
15. Examples (canonical, copy these patterns)
GET /api/v1/reservations?filter[status]=confirmed&sort=-createdAt&limit=50
GET /api/v1/reservations/rsv_01H.../folio
POST /api/v1/reservations (Idempotency-Key, X-Tenant-Id)
PATCH /api/v1/reservations/rsv_01H... (If-Match, Idempotency-Key)
POST /api/v1/reservations/rsv_01H.../check-in (Idempotency-Key)
POST /api/v1/reservations/rsv_01H.../cancel (Idempotency-Key)
POST /api/v1/key-credentials (Idempotency-Key)
POST /api/v1/key-credentials/key_01H.../revoke (Idempotency-Key)
POST /api/v1/payments/pay_01H.../refund (Idempotency-Key)
GET /api/v1/properties/ppt_01H.../rooms?filter[status]=available
GET /api/v1/properties/ppt_01H.../rate-plans
POST /bff/tenant-booking/v1/quotes (no auth; tenant resolved from host)
POST /bff/tenant-booking/v1/quotes/quo_01H.../hold
POST /bff/tenant-booking/v1/bookings (Idempotency-Key)
GET /bff/backoffice/v1/screens/arrivals-board?date=2026-04-23
POST /bff/backoffice/v1/key-credentials/key_01H.../revoke
POST /sync/v1/pull
POST /sync/v1/push
POST /sync/v1/handshake
POST /webhooks/stripe
POST /webhooks/paypal
POST /webhooks/ttlock
GET /health
GET /ready
GET /metrics
16. Anti-patterns (auto-flagged)
/api/v1/getReservations(verb in URL)./api/v1/reservation/{id}(singular)./api/v1/reservations/{id}/folio.json(extension)./api/v1/reservations?id=rsv_...(query as routing)./api/reservations(no version)./v1/api/reservations(wrong order).GET /api/v1/reservations/{id}/cancel(action onGET).POST /api/v1/reservations/cancel?id=rsv_...(id in query for an action)./api/v1/reservations/cancel-by-guest(multi-verb).200 + { error: ... }(use the right status).- Unbounded list responses (no
limit, no cursor). - Returning entity with internal fields (e.g.,
_internalSnapshot,__v). - Inconsistent envelope (some routes wrap in
data, some don't, on the same service).
17. Cross-references
- docs/05-api-design.md — long-form API design rationale.
- .cursor/rules/30-api.mdc — short rules pack loaded by AI tools.
- ERROR_CODES.md — canonical error registry.
- DEFINITION_OF_DONE.md — API DoD checkboxes.
services/<name>/API_CONTRACTS.md— per-service route catalog.
18. Versioning of this document
Same governance as CODING_STANDARDS.md §19. Loosening any rule (e.g., allowing offset pagination) requires an ADR.