Skip to main content

05 — API Design

Companion: 02 Enterprise Architecture · 03 Microservices Catalog · 04 Event-Driven Architecture · 06 Data Models · 07 Security & Tenancy · 12 Desktop Spec · Standards · NAMING · Standards · ERROR_CODES

This is the authoritative API contract document for Ghasi Melmastoon. It defines the surfaces (three BFFs + internal service-to-service REST + async via Pub/Sub), conventions, request and response envelopes, error model, pagination, filtering, versioning, authentication and authorization, the BFF contracts, the offline sync API consumed by the Electron desktop, outbound webhooks, server-sent events, file uploads, rate limiting, internationalization, the OpenAPI publishing pipeline, the Postman collection layout, and concrete worked examples. Every per-service API_CONTRACTS.md defers to this document for cross-cutting concerns.

The cloud is GCP. The desktop is Electron. All naming follows MELMASTOON.* / melmastoon.* per NAMING.md. Error codes resolve through ERROR_CODES.md.


1. API Surfaces

Five surface classes; each has different audience, auth, latency budget, and data shape. They are not interchangeable.

#SurfaceStyleAudienceAuthOwned by
1bff-consumer-serviceREST + JSONAnonymous + low-trust guests on web/mobile (consumer meta)Anonymous + soft sessionbff-consumer-service
2bff-tenant-booking-serviceREST + JSON + SSE (booking-flow heartbeats)Guest scoped to one tenant; in-funnelAnonymous + booking-session token; payment-scoped JWT at checkoutbff-tenant-booking-service
3bff-backoffice-serviceREST + JSON + SSE + sync HTTPAuthenticated staff on Electron desktop; long-lived; offline-firstTenant JWT + device binding + RBAC + ABACbff-backoffice-service
4Internal service-to-service RESTREST + JSONNestJS microservices over the VPCOIDC service-account JWTs (Cloud Run)each service publishes its own /api/v1
5Internal service-to-service asyncGCP Pub/Sub events (envelope per 04)NestJS microservicesPub/Sub IAMeach service per 04 §3

A sixth surface — outbound webhooks to tenant-configured URLs — exists for tenants that want push notifications about their own events (§11). Inbound webhooks from third parties (PayPal, MFS, lock vendors) terminate at payment-gateway-service or lock-integration-service directly and are out of scope of this document beyond the signature verification rules.

Boundary rules:

  • Frontends never call internal services directly. They call a BFF or a service's /api/v1 exposed through Cloud Load Balancer + Cloud Armor.
  • BFFs may read from multiple internal services in parallel; they may write through orchestration only when the saga is explicit and tracked.
  • Internal services never call a BFF.
  • Cross-service writes go through Pub/Sub events (04), not synchronous REST chains > 2 hops.

2. REST Conventions

2.1 Base URL

Production https://api.melmastoon.ghasi.io
Staging https://api.staging.melmastoon.ghasi.io
Tenant booking site https://{tenant-slug}.melmastoon.ghasi.io

2.2 Path Pattern

/api/v<N>/<resource-plural-kebab> # internal service surface
/bff/consumer/v<N>/<feature> # consumer meta BFF
/bff/tenant-booking/v<N>/<feature> # tenant booking BFF
/bff/backoffice/v<N>/<feature> # backoffice BFF
/sync/v<N>/<pull|push> # offline sync
/webhooks/v<N>/<provider> # inbound 3rd-party
  • Lowercase, kebab-case, no trailing slash.
  • Plural-noun collections: /reservations, /properties, /key-credentials.
  • Sub-resources: /reservations/{id}/folio, /properties/{id}/rooms.
  • Action endpoints when REST shape doesn't fit: POST /reservations/{id}/check-in, POST /key-credentials/{id}/revoke.

2.3 HTTP Verbs

VerbSemanticsIdempotentCacheable
GETRead; no side effectsyesyes (per Cache-Control)
POSTCreate or invoke action; non-idempotent unless Idempotency-Key providedonly with Idempotency-Keyno
PUTReplace a resource (rare; we prefer PATCH)yesno
PATCHPartial update; requires If-Match for concurrencyyes (with If-Match)no
DELETERemove or revokeyesno

2.4 Status Codes

CodeUse
200 OKSuccessful read/write returning a body
201 CreatedResource created (POST); Location header points to the resource
202 AcceptedAsync action accepted; operation continues server-side (sagas, AI)
204 No ContentSuccessful DELETE or no-body action
400 Bad RequestMalformed request (missing required header, invalid JSON)
401 UnauthorizedMissing or invalid auth
402 Payment RequiredTenant plan limit hit (MELMASTOON.TENANT.PLAN_LIMIT_EXCEEDED); also payment decline (MELMASTOON.PAYMENT.DECLINED)
403 ForbiddenAuthenticated but disallowed (RBAC/ABAC, tenant-mismatch, surface-mismatch)
404 Not FoundResource missing or hidden by RLS — never differentiated cross-tenant
409 ConflictState transition rejected, room state conflict, idempotency reuse with different body
410 GoneHold expired, quote expired, sync cursor too old
412 Precondition FailedIf-Match mismatch on optimistic concurrency
413 Payload Too LargeSync push batch over budget; theme asset over budget
422 Unprocessable EntityDomain validation failed; cross-tenant reference; invalid enum
429 Too Many RequestsRate limit; carries Retry-After and RateLimit-* headers
500 Internal Server ErrorUncaught error; alert paged
502 Bad GatewayDownstream vendor failure (lock, payment, AI provider)
503 Service UnavailableService degraded; carries Retry-After
504 Gateway TimeoutDownstream timeout (BFF aggregate timeout, payment gateway timeout)

2.5 Idempotency-Key

Mandatory on every POST/PATCH that mutates money, inventory, or key credentials. Strongly recommended on all other writes. Format: ULID. Stored server-side per (tenantId, route, principal, idempotencyKey) for 24 hours; replay returns the original response. Replay with a different body returns 409 MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED.

Idempotency-Key: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT

Idempotency is implemented in a NestJS interceptor (IdempotencyInterceptor) backed by Memorystore.


3. Request Envelope

3.1 Required Headers (most writes)

HeaderPurposeRequired on
Authorization: Bearer <jwt>Access tokenAll authenticated routes
X-Tenant-Id: tnt_<ulid>Tenant context; must match JWT tid claimAll tenant-scoped routes
X-Request-Id: req_<ulid>Request correlation; generated by client or assigned by gatewayAll requests
X-Idempotency-Key: <ulid>Idempotency tokenAll money / inventory / key writes
Accept-Language: <bcp47>Locale negotiation; falls back per §15All routes returning user-visible text
X-Device-Id: dev_<ulid>Device bindingAll bff-backoffice and /sync/v1 routes
traceparent: 00-<traceid>-<spanid>-01W3C trace contextAll routes (auto-injected by gateway if absent)
If-Match: "<etag>"Optimistic concurrency on PATCHAll optimistic-concurrency routes
Content-Type: application/json; charset=utf-8JSON onlyAll requests with a body

3.2 Optional Headers

HeaderPurpose
If-None-Match: "<etag>"Cache validation on GET
Prefer: return=minimalSkip body in 200/201; return Location only
X-Client-Mutation-Id: <ulid>Required on POST /sync/v1/push mutations (§10)
X-Tenant-Locale-Override: <bcp47>Backoffice operator overriding the guest's preferred locale
X-Replay: trueMarks a desktop replay request; server may relax some rate limits

3.3 Body

All bodies are application/json; charset=utf-8. UTF-8 only; no BOM. Numbers fit IEEE 754 doubles except money which is always bigint-string in micro-units (e.g. "totalAmountMicro": "12500000" for AFN 12.50). Currencies are ISO 4217 codes (AFN, USD, IRR, TJS, EUR).


4. Response Envelope

Every response is wrapped. The wrapper is non-negotiable; it is generated by a NestJS interceptor and asserted by every contract test.

4.1 Success — Single Resource

{
"data": {
"id": "rsv_01H8YN7Q2P7GZ4F8Y5CK4MV3DT",
"status": "Confirmed",
"tenantId": "tnt_01H7...",
"...": "..."
},
"meta": {
"requestId": "req_01H8YN7Q2P...",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"apiVersion": "v1.42",
"etag": "\"v9\""
}
}

4.2 Success — Collection

{
"data": [ { "id": "rsv_01H...", "...": "..." } ],
"meta": {
"requestId": "req_01H...",
"traceId": "00-...",
"apiVersion": "v1.42",
"page": {
"limit": 50,
"nextCursor": "eyJsIjoiMjAyNi0wNC0yMlQxMDoxNTowMFoifQ==",
"hasMore": true
},
"filters": { "status": "Confirmed" },
"sort": [ { "field": "createdAt", "dir": "desc" } ]
}
}

4.3 Error — Problem+JSON

We extend RFC 9457 (application/problem+json). Every error carries a stable code from ERROR_CODES.md.

{
"error": {
"type": "https://errors.melmastoon.ghasi.io/reservation/overbooking-blocked",
"title": "Overbooking blocked",
"status": 409,
"detail": "Confirming this booking would exceed allocation; another reservation took the last unit.",
"instance": "/api/v1/reservations/rsv_01H.../confirm",
"code": "MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED",
"traceId": "00-...",
"requestId": "req_01H...",
"tenantId": "tnt_01H...",
"retriable": false,
"retryAfter": null,
"userMessageKey": "errors.reservation.overbooking_blocked",
"docUrl": "https://docs.melmastoon.ghasi.io/errors/reservation/overbooking-blocked",
"runbook": "https://runbooks.melmastoon.ghasi.io/reservation/overbooking",
"errors": [
{ "field": "rooms[0].roomTypeId", "code": "no_availability" }
]
}
}

The Content-Type for error responses is application/problem+json. Clients dispatch on error.code, not on title or detail.


5. Pagination

5.1 Cursor-Based by Default

GET /api/v1/reservations?cursor=eyJsIjoiMjAyNi0wNC0yMlQxMDoxNTowMFoifQ==&limit=20
ParameterTypeDefaultMax
cursoropaque base64url string(start of stream)
limitint50100

Response carries meta.page.nextCursor (null if at end) and meta.page.hasMore (boolean). Cursors are opaque — never decoded by clients. Cursors are valid for 24 hours; an expired cursor returns 410 MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE (sync surface) or 422 MELMASTOON.SEARCH.PAGE_OUT_OF_RANGE (search surface).

5.2 Offset Pagination — Admin Search Only

Cursor pagination cannot do "page 47 of 312". The platform-admin and tenant-admin search consoles need that. Only those routes accept offset:

GET /bff/backoffice/v1/admin/audit-search?offset=920&limit=40

offset is capped at 10,000 — beyond that we return 422 MELMASTOON.SEARCH.PAGE_OUT_OF_RANGE and the operator must add filters.

5.3 Why Cursor First

Cursor pagination tolerates writes during paging (no skipped or duplicated rows), is cheaper at scale (no OFFSET scan), and works trivially in BigQuery / pgvector backed search. Offset pagination is reserved for human "deep-link" UX; cursor handles every other case.


6. Filtering & Sorting

6.1 Filtering

Bracket-notation filters per resource:

GET /api/v1/reservations?filter[status]=Confirmed&filter[checkInAt][gte]=2026-04-01&filter[propertyId]=ppt_01H...

Supported operators per field type:

TypeOperators
stringeq (default), ne, in, contains
enumeq, ne, in
number, int, moneyeq, ne, gt, gte, lt, lte, between
date, datetimeeq, gt, gte, lt, lte, between
booleq
geonear (with radiusKm), within (bounding box)

Each endpoint declares its filterable fields in OpenAPI. Unknown filter fields return 422 MELMASTOON.GENERAL.VALIDATION_FAILED with the unknown field listed.

6.2 Sorting

GET /api/v1/reservations?sort=checkInAt,-createdAt

Comma-separated, leading - for descending. Each endpoint declares sortable fields in OpenAPI; unknown fields return 422.

6.3 Field Selection (Sparse Fieldsets)

For payload-sensitive low-bandwidth surfaces:

GET /bff/consumer/v1/listings?fields=id,name,minPrice,maxPrice,thumbnail

Field selection applies after pagination, filtering, and sorting; it never reduces the work the server does. Used by the consumer mobile app on first paint.


7. Versioning

7.1 URI Versioning

The major version is in the URI: /api/v1/..., /api/v2/.... Minor versions are reflected in the X-API-Version response header (e.g. v1.42). Patch versions are server-internal only.

7.2 Deprecation Headers

When a version is on a sunset path, every response in that version carries:

Deprecation: true
Sunset: Wed, 22 Oct 2026 00:00:00 GMT
Link: <https://docs.melmastoon.ghasi.io/migrations/v1-to-v2>; rel="deprecation"

The minimum overlap window between v<n> and v<n+1> is 6 months. The window may be extended per consumer; it cannot be shortened without an ADR.

7.3 What Counts as Breaking

ChangeBumps version?
Add an optional response fieldNo
Add an optional request field with a defaultNo
Add an enum value the client may not knowNo (clients must ignore unknown enum values)
Tighten a numeric range or regexYes if any prior request would now be rejected
Make an optional request field requiredYes
Remove a response fieldYes
Change a field's typeYes
Rename a pathYes
Change the meaning of an existing enum valueYes
Change the auth requirement of an endpointYes

CI runs openapi-diff between the PR branch's emitted openapi.json and the current production version per service; breaking changes without a /v<n+1>/ path fail the build.

7.4 Event Versioning

Event subjects carry their own version (*.v1, *.v2) per 04 §5. API versioning and event versioning are independent — bumping an API version does not bump every event the API touches.


8. Authentication & Authorization

8.1 Tokens

TokenLifetimeUse
Access JWT15 minutesSent in Authorization: Bearer …; carries sub, tid (tenant), roles, device (optional), aud, iat, exp, jti
Refresh token30 days, rotating (one-time use)Exchanged for a new access + refresh pair; rotation invalidates the prior refresh; reuse triggers session revocation
Device-bound refresh (Electron desktop)30 days, rotating, device-boundRefresh is sealed to the device's hardware-bound key (OS keychain via keytar); cannot be lifted to another machine
Booking session token (consumer)30 minutesSoft session for in-funnel booking on bff-tenant-booking-service (no PII rights, only funnel state)
Service account JWT (Cloud Run)1 hourOIDC-signed by Google for service-to-service internal calls
Webhook signing secretrotated per tenantHMAC-SHA256 (§11)

8.2 SSO

Chain operators (multi-property) and platform staff use OIDC or SAML SSO via iam-service. Identity providers tested:

  • Google Workspace (OIDC)
  • Microsoft Entra ID (OIDC + SAML)
  • Generic OIDC (Auth0, Okta, Keycloak)
  • Generic SAML 2.0

SSO is opt-in per tenant; small independents use the password + WebAuthn flow.

8.3 ABAC + RBAC

Every authenticated request carries a tenant claim (tid) and a role set. Authorization is the intersection of:

LayerMechanism
RBAC (coarse)Roles: Owner, GeneralManager, FrontDesk, Housekeeping, Maintenance, Finance, ChainOperator, MarketingReviewer, PlatformAdmin. Each route declares the role set it accepts.
ABAC (fine)Attributes: tenantId (mandatory), propertyId (when relevant), dataResidency, deviceBound, surface (which BFF), aiHITLAuthority (boolean per role for irreversible AI actions). Policies expressed as Cedar-style rules in iam-service.

The tenantId claim is mandatory on every tenant-scoped route. A request whose Authorization JWT tid does not equal X-Tenant-Id is rejected at the gateway with 403 MELMASTOON.TENANT.NOT_A_MEMBER. Cross-tenant references inside the request body are rejected at the application layer with 422 MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE.

8.4 Device Binding

The Electron desktop pairs with iam-service once per install. Pairing produces a DeviceId and a device-bound key (stored in OS keychain via keytar). Refresh tokens issued to that device are sealed to the key; using the refresh on another machine returns 403 MELMASTOON.IDENTITY.DEVICE_NOT_BOUND and silently revokes the original session. Re-pairing requires either operator MFA or platform-staff override.


9. BFF Contracts

The platform ships three BFFs (02 §5). Each one's scope, audience, and composition responsibilities are summarized below; per-route OpenAPI is in each BFF's repo.

9.1 bff-consumer-service

Audience. Anonymous + low-trust guests on the consumer meta web (Next.js) and the React Native consumer mobile app.

Scope. Cross-tenant meta-search (read-only). Never sees a tenant JWT; never touches lock/payment internals; never composes mutations.

Hot endpoints:

PathVerbPurpose
/bff/consumer/v1/searchPOSTGeo + dates + filters → cross-tenant ranked listings (list view)
/bff/consumer/v1/search/mapPOSTSame query → bounding-box + map-pin payload
/bff/consumer/v1/listings/{propertyId}GETHotel detail page (rooms, amenities, photos, policies) for the meta surface
/bff/consumer/v1/listings/{propertyId}/availabilityGETLight availability snapshot (from search-aggregation-service projection)
/bff/consumer/v1/handoffPOSTMints a soft booking-session token + redirect URL into the tenant booking flow

Composition. Reads exclusively from search-aggregation-service (cross-tenant projection). Never reads from reservation-service, inventory-service, or pricing-service directly; the projection is the only legitimate cross-tenant read path (02 §6.3).

Cache posture. Aggressive — Memorystore + Cloud CDN for static facets (amenity catalog, geo cells); 60s TTL for availability snapshots (with stampede protection).

9.2 bff-tenant-booking-service

Audience. Guest scoped to one tenant; in-funnel; soft session.

Scope. Tenant-themed booking funnel from selection through confirmation. Composes theme + room/rate/availability + price quote + payment redirect.

Hot endpoints:

PathVerbPurpose
/bff/tenant-booking/v1/bootstrapGETTenant theme tokens + locale + content blocks for a property
/bff/tenant-booking/v1/properties/{id}/room-typesGETRoom types + photos for the booking page
/bff/tenant-booking/v1/properties/{id}/ratesGETAvailable rate plans for a stay window
/bff/tenant-booking/v1/availabilityPOSTPer-room-type availability for a stay window (composes inventory + pricing)
/bff/tenant-booking/v1/quotePOSTFinal quote (price + taxes + cancellation policy + currency snapshot)
/bff/tenant-booking/v1/booking-intentsPOSTCaptures intent; mints booking-session token; returns funnel state
/bff/tenant-booking/v1/booking-intents/{id}/payment-methodPOSTSelects payment rail (PayPal / card / cash-on-arrival)
/bff/tenant-booking/v1/booking-intents/{id}/confirmPOSTConfirms (with Idempotency-Key); kicks the booking saga; returns 202 + status URL
/bff/tenant-booking/v1/booking-intents/{id}/statusGET / SSEPolls or streams saga status until confirmed / cancelled

Composition. Reads from theme-config-service, property-service, pricing-service, inventory-service in parallel for bootstrap; writes through reservation-service (which orchestrates the booking saga per 04 §8.1) and payment-gateway-service.

One tenant per request. The BFF asserts the tenantId in the request matches the property; mismatches return 403 MELMASTOON.BFF.SURFACE_MISMATCH.

9.3 bff-backoffice-service

Audience. Authenticated staff on the Electron desktop; long-lived sessions; offline-first.

Scope. Dashboard composition, sync hand-off (§10), AI suggestion fan-out, lock action proxying, multi-property switcher for chain operators.

Hot endpoints:

PathVerbPurpose
/bff/backoffice/v1/session/bootstrapGETOperator profile + tenant + property switcher + feature flags + sync cursors
/bff/backoffice/v1/dashboardGET / SSELive KPIs + alerts + AI insights (SSE for the live tail)
/bff/backoffice/v1/check-in-boardGET / SSEToday's arrivals + departures + room status; updates as events flow
/bff/backoffice/v1/reservations/searchPOSTMulti-facet reservation search (composes reservation-service + inventory-service + billing-service)
/bff/backoffice/v1/key-credentials/issuePOSTProxies to lock-integration-service with audit + retry semantics
/bff/backoffice/v1/ai/suggestionsGET / SSELive AI suggestions (pricing, housekeeping, anomaly) with provenance
/bff/backoffice/v1/ai/decisionsPOSTRecords HITL decisions on AI suggestions
/sync/v1/pullPOSTSync pull (delegated to sync-service); see §10
/sync/v1/pushPOSTSync push (delegated to sync-service); see §10

Composition. Reads from every operational service; writes are delegated downstream (reservations through reservation-service, keys through lock-integration-service, charges through billing-service, etc). The BFF never owns mutation state; it owns the shape the desktop expects.

Strong auth. Tenant JWT + device binding + per-route role check + ABAC; no anonymous traffic; all routes require X-Device-Id.


10. Sync API

The Electron desktop is offline-first (ADR-0003). The platform exposes one sync protocol — /sync/v1/pull and /sync/v1/push — owned by sync-service and surfaced through bff-backoffice-service. This is the only path by which the desktop reads or writes during catch-up; live operations also go through this protocol when the desktop chooses to defer (poor link, batched typing, etc).

10.1 POST /sync/v1/pull

POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Request-Id: req_01H...
Content-Type: application/json; charset=utf-8

{
"since": "eyJsIjoiMjAyNi0wNC0yMlQxMDowMDowMFoifQ==",
"aggregates": ["reservation","room","folio_draft","housekeeping_task","key_credential","staff_schedule","guest_profile"],
"maxBatch": 500
}

Response:

{
"data": {
"deltas": [
{
"aggregateType": "reservation",
"aggregateId": "rsv_01H...",
"version": 14,
"op": "upsert",
"payload": { "...": "..." },
"occurredAt": "2026-04-22T10:14:32.118Z",
"causationEventId": "evt_01H..."
}
],
"nextCursor": "eyJsIjoiMjAyNi0wNC0yMlQxMjowNjozMVoifQ==",
"hasMore": true,
"heartbeatAt": "2026-04-22T12:06:31.001Z"
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}
FieldNotes
sinceOpaque cursor from the prior pull; null for first pull (server returns initial snapshot up to maxBatch)
aggregatesWhitelist of aggregate types the device wants; server intersects with the device's permission scope
maxBatchCapped server-side at 500; larger requests return 400
deltasOrdered by occurredAt then version; server guarantees per-aggregate causal order (per 04 §11)
opupsert or tombstone (soft delete)
nextCursorOpaque; pass back as since
hasMoreTrue if more deltas exist; client should pull again immediately

Cursor expiry. A cursor older than 14 days returns 410 MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE. The desktop must perform a full rebase pull (drop local state for the aggregates concerned and pull from null).

10.2 POST /sync/v1/push

POST /sync/v1/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Content-Type: application/json; charset=utf-8

{
"mutations": [
{
"clientMutationId": "01H8YN7Q2P...",
"aggregateType": "housekeeping_task",
"aggregateId": "hkt_01H...",
"op": "complete",
"payload": { "completedAt": "2026-04-22T11:48:00Z", "actorId": "stf_01H..." },
"baseVersion": 3,
"vectorClock": { "dev_01H...": 12, "server": 8 },
"conflictPolicyHint": "append_only"
}
]
}

Response:

{
"data": {
"results": [
{
"clientMutationId": "01H8YN7Q2P...",
"status": "applied",
"serverState": { "version": 4, "...": "..." }
}
]
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}

Per-mutation status:

StatusMeaningClient action
appliedMutation applied; serverState returnedReplace local state with serverState
noopIdempotent reapply; server already had itReplace local state with serverState
conflictConflict per the aggregate's policy; serverState + conflict returnedSurface to operator (notes/profile) or auto-resolve per policy (02 §8.2)
rejectedServer-authoritative invariant violationDrop local state; pull fresh; surface error to operator

Per-aggregate conflict policy hints. The client sends conflictPolicyHint; the server validates against the canonical policy (02 §8.2) and rejects mismatches with 409 MELMASTOON.SYNC.MUTATION_REJECTED.

413 Payload Too Large. A push batch must stay under 256 KiB compressed and 100 mutations. Larger batches return 413 MELMASTOON.SYNC.PAYLOAD_TOO_LARGE; the client splits and retries. The Electron sync engine pre-splits but the server enforces the limit defensively.

Idempotency-Key on the batch + clientMutationId per mutation. The header guards the batch; the per-mutation id guards the individual mutation. Replay of the batch is safe; partial replay (a sub-set of mutations) is also safe because each mutation is keyed independently.


11. Webhooks (Outbound to Tenants)

Tenants may configure webhook endpoints to receive selected events about their own data. Configured per tenant in tenant-service; delivered by notification-service.

11.1 Signing

Every webhook POST is signed with HMAC-SHA256 using the tenant's webhook secret:

POST https://tenant-host.example/webhooks/melmastoon
X-Melmastoon-Signature: t=1745318400,v1=9c8d... (HMAC-SHA256 of `${t}.${rawBody}`)
X-Melmastoon-Event: melmastoon.reservation.booking.confirmed.v1
X-Melmastoon-Delivery: dlv_01H...
X-Melmastoon-Idempotency-Key: rsv_01H...:confirmed
Content-Type: application/json

The receiver must verify the signature (sample code in the docs), enforce a 5-minute time window on t, and treat X-Melmastoon-Idempotency-Key as the dedup token.

11.2 Retry

AttemptBackoff
1immediate
230 s
32 min
410 min
51 hour
66 hours
724 hours (final)

Failed deliveries are queued; the dashboard exposes a replay button that re-sends a delivery with the original payload + a new dlv_… id (and the same Idempotency-Key).

11.3 Event Subset

Tenants subscribe to a subset of platform events (mostly reservation, payment, lock, notification). The full list is in notification-service's API_CONTRACTS.md. The webhook payload is the platform event envelope (04 §4) with metadata.aiProvenance redacted.

11.4 Schema

The webhook envelope schema is published at https://schemas.melmastoon.ghasi.io/webhooks/v1.json and is versioned per 04 §5.


12. Server-Sent Events (SSE)

For backoffice live UX (KPIs, check-in feed, AI suggestions), we use SSE. SSE wins over WebSockets here because (a) it's HTTP-native (works through every corporate proxy), (b) it's one-way (server → client) which matches the use case, (c) it has built-in reconnect with Last-Event-Id, and (d) it costs less than WebSocket on Cloud Run.

GET /bff/backoffice/v1/dashboard HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
Last-Event-Id: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT

Stream:

event: dashboard.kpis
id: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
data: {"occupancyRate":0.78,"adrMicro":"4500000","arrivalsToday":12,"...":"..."}

event: checkin.feed
id: 01H8YN7Q2Q...
data: {"reservationId":"rsv_01H...","status":"checked_in","at":"2026-04-22T12:01:11Z"}

: heartbeat
AspectDetail
Transporttext/event-stream; cache-control: no-cache; connection: keep-alive
ReconnectClient passes Last-Event-Id; server resumes from that point (kept in Memorystore for 60s)
HeartbeatComment line every 15s to defeat idle proxies
CloseServer closes after 30 min idle; client reconnects

12.1 SSE Channels

ChannelSource
dashboard.kpisanalytics-service snapshots projected per tenant
checkin.feedreservation-service events filtered to today's window
housekeeping.boardhousekeeping-service events
ai.suggestionsai-orchestrator-service decisions filtered by operator role
lock.eventslock-integration-service events for the operator's properties
sync.statussync-service — sync cursor moves + conflict notifications

13. File Upload

Direct-to-Cloud-Storage uploads via short-lived signed URLs minted by file-storage-service. Clients never POST file bytes to our APIs directly.

13.1 Flow

1. Client → POST /api/v1/file-uploads
{ kind: 'property_photo', filename, mimeType, sizeBytes, sha256 }
2. file-storage-service validates kind + size budget + mime allow-list
3. file-storage-service mints a V4 signed URL (PUT, 15-min TTL, conditioned on Content-Length + sha256)
4. Returns { uploadUrl, mediaId: 'med_01H...', maxSizeBytes }
5. Client → PUT <uploadUrl> (uploads bytes directly to Cloud Storage)
6. Client → POST /api/v1/file-uploads/{mediaId}/notify-complete
7. file-storage-service runs virus scan; emits melmastoon.file.uploaded.v1 + .scanned.v1

13.2 Chunked Uploads

For media > 32 MiB (rare — mostly videos in tenant theme content), the signed URL is a resumable upload session URL. The client uses standard Cloud Storage resumable-upload semantics; the server only sees the notify-complete callback.

13.3 Size & Type Budgets

KindMax sizeMIME allow-list
property_photo8 MiBimage/jpeg, image/png, image/webp, image/avif
theme_logo1 MiBimage/svg+xml, image/png, image/webp
theme_hero8 MiBimage/jpeg, image/webp, image/avif
theme_video32 MiBvideo/mp4 (h.264 baseline)
guest_id_doc4 MiBimage/jpeg, image/png, application/pdf
invoice_attachment4 MiBapplication/pdf

Over-budget uploads return 413 MELMASTOON.THEME.ASSET_TOO_LARGE (theme) or 413 with a sized error code on the relevant domain.


14. Rate Limiting

Memorystore-backed token bucket per (tenantId, route, principal) tuple. Standard RateLimit-* response headers (RFC 9239 draft).

RateLimit-Limit: 60
RateLimit-Remaining: 47
RateLimit-Reset: 23
RateLimit-Policy: 60;w=60

When exhausted: 429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After: <seconds>.

14.1 Default Buckets

Surface / route classPer-principalPer-tenantPer-IP
bff-consumer-service GET (search/listings)600 / min120 / min
bff-consumer-service POST (handoff)60 / min30 / min
bff-tenant-booking-service GET120 / min1200 / min240 / min
bff-tenant-booking-service POST (booking-intents/confirm)5 / min60 / min30 / min
bff-backoffice-service standard600 / min6000 / min
bff-backoffice-service AI endpoints60 / min600 / min
/sync/v1/pull30 / min600 / min
/sync/v1/push30 / min (with X-Replay: true → 60)600 / min
notification-service sendper-tenant plan limit
Webhook delivery (outbound)100 / s per endpoint
payment-gateway-service create-intent5 / min per booking-session600 / min60 / min

14.2 Per-Tenant Plan Overrides

Plans (Free, Starter, Pro, Plus) carry rate-limit multipliers stored in tenant-service. tenant-service writes the effective limits to Memorystore on plan change; the rate-limit interceptor reads from Memorystore (with a 60s in-process cache).

14.3 Burst Allowance

Each bucket allows a 2x burst over a 5s window. The bucket refills linearly to baseline. This prevents legitimate bursts (a front-desk operator confirming a queue of 8 walk-in check-ins in 30s) from being blocked.


15. i18n

15.1 Accept-Language Honored

Every BFF and most internal services honor Accept-Language for user-visible strings (error messages, validation errors, AI-drafted text). The negotiation order:

  1. Accept-Language header (BCP 47)
  2. Tenant preference (tenant-servicedefaultLocale)
  3. Property preference (property-servicelocaleOverride)
  4. Platform default (en)

The chosen locale is returned in the Content-Language response header.

15.2 Per-Tenant Fallback Chain

A tenant declares an ordered locale list, e.g. [ps, fa-AF, en]. If a string is missing in ps, fall through to fa-AF, then en. This is enforced at the i18n bundle layer (@ghasi/ui-melmastoon/i18n) and at API responses with userMessageKey.

15.3 Locale-Aware Formatting

Money, dates, and numerals are returned machine-readable in API bodies (ISO currency + ISO date + IEEE numbers) and rendered by clients per locale. We never return preformatted human strings except in error.detail (which is locale-aware via userMessageKey).

15.4 RTL/LTR

Direction is a property of the locale, not of any individual API call. Clients derive direction from the locale (ps, fa, ar → RTL; everything else → LTR) and apply logical CSS properties; the API never carries direction hints.


16. Error Model & Retry Guidance

The full registry is in ERROR_CODES.md. The table below summarizes the platform-wide retry guidance the desktop and the BFF clients use.

HTTPCodeRetriableClient retry strategy
401MELMASTOON.IDENTITY.TOKEN_EXPIREDn/aRefresh + retry once
401MELMASTOON.IDENTITY.SESSION_REVOKEDnoRe-login; do not retry
401MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALIDnoDrop
402MELMASTOON.PAYMENT.DECLINEDnoSurface to user
402MELMASTOON.TENANT.PLAN_LIMIT_EXCEEDEDnoSurface to admin
403MELMASTOON.IDENTITY.DEVICE_NOT_BOUNDnoRe-pair desktop
403MELMASTOON.TENANT.NOT_A_MEMBERnoDrop
404MELMASTOON.GENERAL.RESOURCE_NOT_FOUNDnoDrop
409MELMASTOON.RESERVATION.OVERBOOKING_BLOCKEDnoSurface alternatives to user
409MELMASTOON.SYNC.CONFLICT_DETECTEDper policyResolve per aggregate policy
409MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSEDnoBug — log + drop
410MELMASTOON.RESERVATION.HOLD_EXPIREDnoRe-quote + retry funnel
410MELMASTOON.SYNC.CURSOR_OUT_OF_RANGEnoFull rebase pull
412MELMASTOON.GENERAL.PRECONDITION_FAILEDnoRe-fetch + retry with new ETag
413MELMASTOON.SYNC.PAYLOAD_TOO_LARGEnoSplit batch + retry
422MELMASTOON.GENERAL.VALIDATION_FAILEDnoSurface field errors
422MELMASTOON.GENERAL.CROSS_TENANT_REFERENCEnoBug — log + drop
429MELMASTOON.GENERAL.RATE_LIMITEDyesHonor Retry-After; exponential backoff with jitter
500MELMASTOON.GENERAL.INTERNALyesExp backoff, max 3 attempts; report
502MELMASTOON.LOCK.VENDOR_UNREACHABLEyesExp backoff (10s, 30s, 2min); fall back to mechanical key flow if persistent
502MELMASTOON.AI.PROVIDER_UNAVAILABLEyesExp backoff; degrade UI to non-AI
502MELMASTOON.BFF.UPSTREAM_UNAVAILABLEyesExp backoff; partial render if BFF returned partial data
503MELMASTOON.LOCK.CARD_ENCODER_OFFLINEyesQueue locally; retry on reconnect
504MELMASTOON.PAYMENT.GATEWAY_TIMEOUTyesExp backoff; do not double-charge — Idempotency-Key protects
504MELMASTOON.BFF.UPSTREAM_TIMEOUTyesExp backoff

Universal client rules:

  1. Always retry with the same Idempotency-Key after a 5xx; the server collapses replays.
  2. Never retry without backoff; minimum 1s + jitter.
  3. Always honor Retry-After when present.
  4. Never retry on 4xx unless the code is 429.

17. OpenAPI

Every service emits its openapi.json to its repo root via a build-time script (scripts/emit-openapi.ts). The CI pipeline:

  1. Builds the service.
  2. Runs the emit script; fails the build if openapi.json is stale or invalid.
  3. Runs openapi-diff against the production version published at https://schemas.melmastoon.ghasi.io/openapi/<service>/v1.json.
  4. Fails the build on breaking changes unless the version path bumps (per §7.3).

A portal aggregator job runs nightly:

  1. Pulls every service's openapi.json from Artifact Registry.
  2. Tags each with its capability cluster (02 §4).
  3. Publishes a unified portal at https://docs.melmastoon.ghasi.io/api/ with per-cluster navigation, code samples per language (TypeScript / cURL / Postman), and try-it-out for every endpoint marked safeForPortal: true.

The aggregator also emits a machine-readable index (openapi-index.json) consumed by the SDK generator (TypeScript SDK) and the contract-test harness.


18. Postman Collections

We generate Postman collections per BFF and per service; the generator runs on the same CI step that emits OpenAPI. Each collection ships with:

FolderContents
Environmentslocal, staging, prod. Variables: baseUrl, tenantId, accessToken, refreshToken, deviceId, idempotencyKey (dynamic via Postman script).
AuthPre-request scripts that exchange refresh → access if accessToken is missing or expired.
Smoke flowsEnd-to-end happy paths for booking, check-in, checkout, key issuance, sync pull, sync push.
Negative flowsOverbooking, payment decline, idempotency reuse, cursor expiry.
Per-resourceOne folder per resource with full CRUD + actions.

Generated collections are versioned per service major version; consumers fetch from https://docs.melmastoon.ghasi.io/postman/<service>-v1.postman_collection.json. We follow Postman best practices for collection structure: descriptive folder names, request descriptions populated from OpenAPI, examples for every response code we document.


19. Examples

Concrete request/response pairs for the highest-value flows. Headers truncated where boilerplate (auth, trace, content-type).

19.1 Search Hotels (Consumer Meta)

POST /bff/consumer/v1/search HTTP/1.1
Content-Type: application/json
Accept-Language: ps,fa-AF;q=0.9,en;q=0.7

{
"geo": { "lat": 34.5553, "lng": 69.2075, "radiusKm": 5 },
"checkIn": "2026-05-12",
"checkOut": "2026-05-15",
"guests": { "adults": 2, "children": 1 },
"rooms": 1,
"filters": {
"priceMicro": { "lte": "8000000" },
"amenities": ["wifi","breakfast","parking"],
"minRating": 4.0
},
"sort": "price_asc",
"limit": 20
}
{
"data": [
{
"propertyId": "ppt_01H8YN7Q2P...",
"tenantId": "tnt_01H7...",
"name": "Park Star Guesthouse",
"thumbnail": "https://cdn.melmastoon.ghasi.io/m/ppt_01H.../hero.webp",
"geo": { "lat": 34.5561, "lng": 69.2082 },
"rating": 4.4,
"amenities": ["wifi","breakfast","parking","laundry"],
"minPriceMicro": "5500000",
"maxPriceMicro": "7200000",
"currency": "AFN",
"availabilitySummary": { "anyAvailable": true, "lowStock": false }
}
],
"meta": {
"requestId": "req_01H...",
"page": { "limit": 20, "nextCursor": "eyJzIjoiMjAifQ==", "hasMore": true },
"filters": { "priceMicro": {"lte": "8000000"} },
"sort": [{"field": "price", "dir": "asc"}]
}
}

19.2 Check Availability (Tenant Booking)

POST /bff/tenant-booking/v1/availability HTTP/1.1
X-Tenant-Id: tnt_01H7...
Content-Type: application/json

{
"propertyId": "ppt_01H...",
"checkIn": "2026-05-12",
"checkOut": "2026-05-15",
"guests": { "adults": 2, "children": 1 },
"ratePlanFilter": ["BAR","WEEKLY"]
}
{
"data": {
"stay": { "checkIn": "2026-05-12", "checkOut": "2026-05-15", "nights": 3 },
"roomTypes": [
{
"roomTypeId": "rmt_01H...",
"name": "Deluxe King",
"capacity": { "adults": 2, "children": 2 },
"available": 4,
"rates": [
{
"ratePlanId": "rate_01H...",
"code": "BAR",
"totalMicro": "16500000",
"perNightMicro": "5500000",
"currency": "AFN",
"cancellation": { "freeUntil": "2026-05-10T00:00:00Z" }
}
]
}
]
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}

19.3 Create Booking (Tenant Booking)

POST /bff/tenant-booking/v1/booking-intents/bki_01H.../confirm HTTP/1.1
Authorization: Bearer <booking-session-token>
X-Tenant-Id: tnt_01H7...
X-Idempotency-Key: 01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Content-Type: application/json

{
"guest": {
"fullName": "Layla Karimi",
"email": "layla@example.com",
"phone": "+93700000000",
"preferredLocale": "ps"
},
"paymentMethod": { "rail": "card", "tokenId": "pmt_01H..." }
}
HTTP/1.1 202 Accepted
Location: /bff/tenant-booking/v1/booking-intents/bki_01H.../status
Content-Type: application/json

{
"data": {
"bookingIntentId": "bki_01H...",
"status": "processing",
"sagaId": "sga_01H...",
"statusUrl": "/bff/tenant-booking/v1/booking-intents/bki_01H.../status"
},
"meta": { "requestId": "req_01H...", "traceId": "00-..." }
}

19.4 Check-In (Backoffice)

POST /api/v1/reservations/rsv_01H.../check-in HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P...
Content-Type: application/json
If-Match: "v9"

{
"actorId": "stf_01H...",
"guestIdDocument": { "mediaId": "med_01H...", "type": "national_id" },
"issueKey": true,
"lockTarget": { "roomId": "rmu_01H...", "vendorHint": "ttlock" }
}
{
"data": {
"reservationId": "rsv_01H...",
"status": "CheckedIn",
"version": 10,
"folio": { "id": "fol_01H...", "status": "open", "balanceMicro": "0" },
"keyCredentials": [
{ "id": "key_01H...", "validFrom": "2026-05-12T14:00:00Z", "validUntil": "2026-05-15T11:00:00Z", "vendor": "ttlock" }
],
"saga": { "id": "sga_01H...", "status": "completed" }
},
"meta": { "requestId": "req_01H...", "etag": "\"v10\"" }
}

19.5 Issue Key (Backoffice — Standalone)

POST /api/v1/key-credentials HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P...
Content-Type: application/json

{
"reservationId": "rsv_01H...",
"roomId": "rmu_01H...",
"validFrom": "2026-05-12T14:00:00Z",
"validUntil": "2026-05-15T11:00:00Z",
"vendorHint": "salto",
"delivery": { "channel": "card_encoder", "encoderId": "enc_01"}
}
{
"data": {
"id": "key_01H...",
"vendor": "salto",
"vendorReference": "salto-cred-9f8a...",
"validFrom": "2026-05-12T14:00:00Z",
"validUntil": "2026-05-15T11:00:00Z",
"delivery": { "channel": "card_encoder", "encoderId": "enc_01", "encodedAt": "2026-05-12T14:01:11Z" },
"status": "issued"
},
"meta": { "requestId": "req_01H..." }
}

If the lock vendor times out: 502 MELMASTOON.LOCK.KEY_ISSUE_FAILED with retriable: true. The Electron desktop queues the issue request and retries with the same Idempotency-Key.

19.6 Pull Sync (Desktop)

POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
Content-Type: application/json

{
"since": null,
"aggregates": ["reservation","room","folio_draft","housekeeping_task","key_credential"],
"maxBatch": 500
}
{
"data": {
"deltas": [
{
"aggregateType": "reservation",
"aggregateId": "rsv_01H...",
"version": 9,
"op": "upsert",
"payload": {
"id": "rsv_01H...",
"status": "Confirmed",
"checkIn": "2026-05-12",
"checkOut": "2026-05-15",
"guest": { "fullName": "Layla Karimi", "preferredLocale": "ps" }
},
"occurredAt": "2026-04-22T08:14:32Z"
}
],
"nextCursor": "eyJsIjoiMjAyNi0wNC0yMlQxMDowMDowMFoifQ==",
"hasMore": true,
"heartbeatAt": "2026-04-22T10:00:00Z"
},
"meta": { "requestId": "req_01H..." }
}

19.7 Push Sync (Desktop)

POST /sync/v1/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Device-Id: dev_01H...
X-Idempotency-Key: 01H8YN7Q2P...
Content-Type: application/json

{
"mutations": [
{
"clientMutationId": "01H8YN7Q2P...",
"aggregateType": "housekeeping_task",
"aggregateId": "hkt_01H...",
"op": "complete",
"payload": { "completedAt": "2026-04-22T11:48:00Z", "actorId": "stf_01H..." },
"baseVersion": 3,
"vectorClock": { "dev_01H...": 12, "server": 8 },
"conflictPolicyHint": "append_only"
},
{
"clientMutationId": "01H8YN7Q2Q...",
"aggregateType": "guest_profile",
"aggregateId": "gst_01H...",
"op": "patch",
"payload": { "preferences": { "pillowType": "soft" } },
"baseVersion": 1,
"vectorClock": { "dev_01H...": 13, "server": 1 },
"conflictPolicyHint": "lww"
}
]
}
{
"data": {
"results": [
{
"clientMutationId": "01H8YN7Q2P...",
"status": "applied",
"serverState": { "id": "hkt_01H...", "version": 4, "status": "Completed" }
},
{
"clientMutationId": "01H8YN7Q2Q...",
"status": "conflict",
"serverState": { "id": "gst_01H...", "version": 3, "preferences": { "pillowType": "firm" } },
"conflict": { "policy": "lww", "winner": "server", "reason": "server_timestamp_later" }
}
]
},
"meta": { "requestId": "req_01H..." }
}

20. Anti-Patterns

Anti-patternWhy it's bannedWhat we do instead
Synchronous waterfalls > 2 hopsCouples availability; defeats offline-firstAsync via Pub/Sub + saga (04 §8); BFFs do parallel reads, not chained writes
RPC verbs in paths (/getReservation, /createBooking)Defeats REST; defeats caching; defeats OpenAPI toolingResource paths + standard verbs; action endpoints (/check-in, /revoke) only when REST shape doesn't fit
Leaking internal IDs (raw UUIDs, internal numeric ids, internal email addresses)Couples client to internal schema; breaks ID rotation; risks PII leakULIDs with service-chosen prefixes (NAMING.md); never expose internal counters or vendor reference IDs except in the field that domain requires
Untyped error responsesClients hand-parse English detail; breaks i18n; breaks alertingProblem+JSON with code per ERROR_CODES.md; clients dispatch on code
Idempotency by client backoff aloneDouble-charge on retry-after-successIdempotency-Key mandatory on money/inventory/key writes; server collapses replays
Cross-tenant joins in BFFsDefeats RLS; defeats per-tenant cache; defeats auditCross-tenant only via search-aggregation-service's projection; everything else tenant-scoped
Bodies with secrets (API keys, refresh tokens, lock vendor credentials)Logged accidentally; cached in proxiesSecret material in headers (auth) or in Secret Manager; never in JSON bodies
Direct frontend → microservice callsCouples client to internal topology; defeats BFF; bypasses rate limitsAll client traffic goes through a BFF or /sync/v1; internal services not exposed to the public LB
Client-side decoded cursorsCouples client to server's pagination implementation; breaks evolutionCursors are opaque; clients pass back exactly what they received
Last-write-wins on money or inventorySilent data lossAppend-only or server-authoritative (02 §8.2)
Polling where SSE appliesWastes the desktop's link budget; blows rate limitsSSE for live channels (§12)
Versioning by header instead of URIHides version from logs, traces, and CDN cache keysURI versioning (/api/v1); header carries minor version only
Unbounded responses (no limit, no pagination)Eats memory, breaks gateways, slows the desktop on a flaky linkEvery collection paginated; limit capped at 100
AI calls outside ai-orchestrator-serviceBypasses moderation, provenance, budgetAll AI through ports/AIClient per 02 §9

Cross-references: per-service API contracts live in services/<service-name>/API_CONTRACTS.md. The event surface that REST writes feed (via the outbox) is in 04 Event-Driven Architecture. The Electron sync client implementation that consumes /sync/v1 is in 12 Desktop Spec. The next document in the strategic set is 06 Data Models.