Skip to main content

API_CONTRACTS — bff-consumer-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DOMAIN_MODEL · SECURITY_MODEL

Cross-cutting: 05 API Design · Standards · ERROR_CODES · Standards · NAMING

1. Surface, base URL, conventions

  • Base URL (prod): https://api.melmastoon.ghasi.io/bff/consumer/v1
  • Base URL (staging): https://api.staging.melmastoon.ghasi.io/bff/consumer/v1
  • Surface: REST + JSON. Anonymous-first. No SSE. No WebSockets. Cloud CDN sits in front.
  • Auth: anonymous. A gms_<ulid> cookie is minted on first response; subsequent requests must echo it. Phase 2 may add an Authorization: Bearer <jwt> upgrade path; until then, no JWT is accepted.
  • Headers: request envelope per 05 §3. Errors per ERROR_CODES.
  • Versioning: path-prefix major version (/v1). Breaking shape changes require /v2.
  • OpenAPI: generated from controllers; published to https://docs.melmastoon.ghasi.io/openapi/bff-consumer.json.

2. Common request headers

HeaderRequiredNotes
Cookie: gms_id=<ulid>first request optional, then requiredCross-tenant guest session
X-Request-IdrecommendedGenerated by client; gateway fills if absent
Accept-LanguagerecommendedBCP-47; resolves locale
X-CurrencyoptionalISO 4217; overrides session preference
X-Idempotency-Keyrequired on POST/DELETE mutating routesULID
traceparentautoW3C trace context
X-Recaptcha-TokenconditionalWhen server returned MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT
X-Campaign-Source / X-Campaign-Medium / X-Campaign-NameoptionalUTM passthrough; persisted on session

3. Common response headers

HeaderNotes
Set-Cookie: gms_id=<ulid>; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000On bootstrap
X-Request-IdEchoed
X-Cache: HIT / MISS / STALE / BYPASSCache disposition
X-Cache-KeyWhen Debug-Mode: true and request is from an allow-listed dev IP
Cache-ControlPer-route (see below)
VaryAccept-Language, X-Currency, Accept-Encoding
RateLimit-Limit / RateLimit-Remaining / RateLimit-ResetRFC 9239
traceparentEchoed

4. Endpoint catalogue

MethodPathPurposeCacheIdempotent
POST/searchRanked list viewedge: 15 s; app: 60 syes
POST/search/mapBounding-box pinsedge: 15 s; app: 60 syes
GET/hotels/{propertyId}Hotel detail view-modeledge: 60 s; app: 5 minyes
GET/hotels/{propertyId}/availabilityLight availability snapshotedge: 30 s; app: 60 syes
GET/facetsStatic + dynamic facet catalogedge: 1 h; app: 1 hyes
POST/wishlistAdd to wishlistnoneyes (Idempotency-Key)
DELETE/wishlist/{propertyId}Remove from wishlistnoneyes
GET/wishlistRead wishlistnoneyes
POST/handoff/{tenantId}/{propertyId}Mint signed handoff linknoneyes (Idempotency-Key)
GET/sessionRead sanitized sessionnoneyes
POST/session/localeSet locale preferencenoneyes
POST/session/currencySet currency preferencenoneyes
POST/session/clearClear (purge) sessionnoneyes
POST/telemetry/page-viewClient-emitted page-viewnoneyes
POST/telemetry/clickClick eventnoneyes
GET/health/liveLivenessnoneyes
GET/health/readyReadiness (checks Memorystore + Postgres + upstream circuit-breakers)noneyes

5. POST /search

5.1 Request

POST /bff/consumer/v1/search HTTP/1.1
Host: api.melmastoon.ghasi.io
Cookie: gms_id=gms_01H8YN7Q2P7GZ4F8Y5CK4MV3DT
Accept-Language: ps-AF, en;q=0.5
X-Currency: AFN
X-Request-Id: req_01H8YN7QF9RTBRZG4F8Y5CK4MV
Content-Type: application/json; charset=utf-8

{
"geo": {
"mode": "city",
"city": "Kabul",
"country": "AF"
},
"dates": { "checkIn": "2026-05-12", "checkOut": "2026-05-15" },
"occupancy": { "adults": 2, "children": 0, "rooms": 1 },
"filters": {
"priceRange": { "min": 0, "max": 500000, "currency": "AFN" },
"amenities": ["wifi", "halal-kitchen"],
"starRating": [3, 4, 5],
"guestRatingMin": 7.5
},
"sortKey": "recommended",
"page": { "limit": 20, "offset": 0 },
"enrichTopN": 10,
"searchSessionId": null
}

5.2 Response (200)

{
"data": {
"searchSessionId": "srs_01H8YN7QV4D8KZ4F8Y5CK4MV3D",
"results": [
{
"propertyId": "ppt_01H8YN7QV4D8KZ4F8Y5CK4MV3E",
"tenantId": "tnt_01H8YN7QV4D8KZ4F8Y5CK4MV3F",
"tenantSlug": "kabul-grand-hotel",
"name": { "default": "Kabul Grand Hotel", "localized": { "ps-AF": "هوتل لوی کابل" } },
"city": "Kabul",
"country": "AF",
"geo": { "lat": 34.5328, "lng": 69.1718 },
"thumbnail": { "url": "https://media.melmastoon.ghasi.io/ppt_.../hero.webp", "alt": "Hero" },
"starRating": 4,
"guestRating": { "value": 8.4, "count": 312 },
"amenityHighlights": ["wifi", "halal-kitchen", "parking", "airport-shuttle", "prayer-room"],
"brandPeek": {
"primaryColor": "#0F4C81",
"logoUrl": "https://media.melmastoon.ghasi.io/tnt_.../logo.svg",
"brandName": { "default": "Kabul Grand" }
},
"rateSnapshot": {
"cheapestNightlyMinor": "320000",
"totalForStayMinor": "960000",
"currency": "AFN",
"currencyDisplayPolicy": "user-preferred",
"fxSnapshotId": "fxs_01H8YN7QV4D8KZ4F8Y5CK4MV3G",
"capturedAt": "2026-04-23T09:14:22.041Z",
"ttlExpiresAt": "2026-04-23T09:15:22.041Z",
"isStale": false
},
"badges": ["shariaCompliant"],
"distanceKm": null
}
],
"page": { "limit": 20, "offset": 0, "total": 47 },
"facetSummary": {
"amenityCounts": { "wifi": 47, "halal-kitchen": 31, "parking": 28 },
"priceHistogramAfn": [{ "bucket": "0-150000", "count": 12 }, { "bucket": "150000-300000", "count": 22 }],
"starCounts": { "3": 11, "4": 22, "5": 14 }
}
},
"meta": {
"requestId": "req_01H8YN7QF9RTBRZG4F8Y5CK4MV",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"fromCache": true,
"cacheKey": "consumer:search:list:6f3c1...",
"ttlSecondsRemaining": 41
}
}

5.3 Errors

CodeHTTPWhen
MELMASTOON.GENERAL.VALIDATION_FAILED422Invalid date range, occupancy, geo
MELMASTOON.BFF.CONSUMER.LOCALE_NOT_SUPPORTED422Accept-Language resolves to unsupported tag
MELMASTOON.BFF.CONSUMER.CURRENCY_NOT_SUPPORTED422X-Currency not in {AFN, USD, EUR, IRR, PKR, AED, GBP}
MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT429Bot-detector threshold exceeded; carries Retry-After and X-Recaptcha-SiteKey
MELMASTOON.GENERAL.RATE_LIMITED429Per-fingerprint or per-IP bucket exhausted
MELMASTOON.BFF.CONSUMER.UPSTREAM_BUDGET_EXCEEDED504Per-request fanout budget hit
MELMASTOON.SEARCH_AGGREGATION.UPSTREAM_UNAVAILABLE502Aggregation service down; we serve last cached result if available, else 502

6. POST /search/map

Identical request shape with geo.mode = "bounding-box" required and boundingBox populated. Response carries pins[] (MapPinVM[]) up to 250, plus an optional spotlight pin (ListingCardVM) when spotlightPropertyId was supplied.

{
"geo": { "mode": "bounding-box", "boundingBox": { "swLat": 34.40, "swLng": 69.05, "neLat": 34.65, "neLng": 69.30 } },
"dates": { "checkIn": "2026-05-12", "checkOut": "2026-05-15" },
"occupancy": { "adults": 2, "children": 0, "rooms": 1 },
"spotlightPropertyId": "ppt_01H8YN7QV4D8KZ4F8Y5CK4MV3E"
}
{
"data": {
"pins": [
{ "propertyId": "ppt_01H...", "geo": { "lat": 34.5328, "lng": 69.1718 }, "brandColor": "#0F4C81" }
],
"spotlight": { /* ListingCardVM */ },
"boundingBox": { /* echoed */ },
"pinCount": 184
}
}

7. GET /hotels/{propertyId}

GET /bff/consumer/v1/hotels/ppt_01H8YN7QV4D8KZ4F8Y5CK4MV3E HTTP/1.1
Cookie: gms_id=gms_01H...
Accept-Language: ps-AF
X-Currency: AFN

Response (200):

{
"data": {
"property": {
"id": "ppt_01H...",
"tenantId": "tnt_01H...",
"tenantSlug": "kabul-grand-hotel",
"name": { "default": "Kabul Grand Hotel", "localized": { "ps-AF": "هوتل لوی کابل" } },
"address": { "line1": "...", "city": "Kabul", "country": "AF" },
"geo": { "lat": 34.5328, "lng": 69.1718 },
"starRating": 4,
"guestRating": { "value": 8.4, "count": 312 },
"description": { "default": "...", "localized": { "ps-AF": "..." } }
},
"rooms": [
{ "roomTypeId": "rmt_01H...", "name": "Deluxe Twin", "occupancyMax": 2, "bedConfiguration": "2 twin", "amenities": ["ac","tv","minibar"], "thumbnail": { "url": "...", "alt": "..." } }
],
"amenities": [{ "key": "wifi", "name": "WiFi", "category": "connectivity" }],
"photos": [{ "url": "...", "alt": "...", "isHero": true }],
"policies": { "cancellation": "...", "checkIn": "14:00", "checkOut": "11:00" },
"brandPeek": { "primaryColor": "#0F4C81", "logoUrl": "...", "brandName": { "default": "Kabul Grand" } },
"cheapestRateSnapshot": { /* RateSnapshotVM */ },
"priceCalendarPreview": [
{ "date": "2026-05-12", "cheapestMinor": "320000", "currency": "AFN" }
],
"similarProperties": [ /* up to 4 ListingCardVM */ ],
"popularitySignals": { "bookedLast24h": 7, "viewedLast1h": 41 },
"handoffHint": { "url": "/bff/consumer/v1/handoff/tnt_01H.../ppt_01H...?dates=2026-05-12_2026-05-15&adults=2", "ttlSeconds": 1800 }
},
"meta": { /*...*/ }
}

Errors include MELMASTOON.GENERAL.RESOURCE_NOT_FOUND (404) when the property is missing or the underlying tenant is suspended.

8. GET /hotels/{propertyId}/availability

GET /bff/consumer/v1/hotels/ppt_01H.../availability?from=2026-05-12&to=2026-05-15&adults=2&rooms=1
{
"data": {
"propertyId": "ppt_01H...",
"tenantId": "tnt_01H...",
"stayWindow": { "checkIn": "2026-05-12", "checkOut": "2026-05-15" },
"isFullySoldOut": false,
"anyAvailable": true,
"minNightlyMinor": "320000",
"maxNightlyMinor": "780000",
"currency": "AFN",
"byRoomType": [
{ "roomTypeId": "rmt_01H...", "roomsAvailable": 4, "minNightlyMinor": "320000" }
],
"fxSnapshotId": "fxs_01H...",
"capturedAt": "2026-04-23T09:14:22.041Z",
"isStale": false
}
}

9. GET /facets

GET /bff/consumer/v1/facets?country=AF&region=kabul
{
"data": {
"amenities": [
{ "key": "wifi", "name": { "default": "WiFi" }, "category": "connectivity", "icon": "wifi" },
{ "key": "halal-kitchen", "name": { "default": "Halal Kitchen" }, "category": "dining", "icon": "halal" },
{ "key": "prayer-room", "name": { "default": "Prayer Room" }, "category": "religious", "icon": "prayer" }
],
"propertyTypes": ["hotel", "guesthouse", "hostel", "apartment"],
"neighbourhoods": [{ "key": "wazir-akbar-khan", "name": { "default": "Wazir Akbar Khan" }, "centroid": { "lat": 34.535, "lng": 69.180 } }],
"currencies": ["AFN","USD","EUR","IRR","PKR","AED","GBP"],
"locales": ["ps-AF","fa-AF","en","ar-SA","ur-PK"]
},
"meta": { "fromCache": true, "ttlSecondsRemaining": 3540 }
}

10. POST /wishlist

POST /bff/consumer/v1/wishlist HTTP/1.1
Cookie: gms_id=gms_01H...
X-Idempotency-Key: 01H8YN7QF9RTBRZG4F8Y5CK4XYZ
Content-Type: application/json

{ "propertyId": "ppt_01H...", "tenantId": "tnt_01H...", "source": "detail", "note": "May trip" }
HTTP/1.1 201 Created
Location: /bff/consumer/v1/wishlist/ppt_01H...
Content-Type: application/json

{
"data": { "wishlistId": "wsh_01H...", "propertyId": "ppt_01H...", "tenantId": "tnt_01H...", "addedAt": "2026-04-23T09:14:22.041Z", "source": "detail" }
}

Errors: MELMASTOON.BFF.CONSUMER.WISHLIST_LIMIT_EXCEEDED (422), MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED (409).

11. DELETE /wishlist/{propertyId}

DELETE /bff/consumer/v1/wishlist/ppt_01H... HTTP/1.1
Cookie: gms_id=gms_01H...

HTTP/1.1 204 No Content

12. GET /wishlist

{
"data": {
"items": [
{
"wishlistId": "wsh_01H...",
"card": { /* ListingCardVM with rateSnapshot best-effort */ },
"addedAt": "2026-04-23T09:14:22.041Z",
"note": "May trip"
}
],
"count": 4,
"limit": 100
}
}

13. POST /handoff/{tenantId}/{propertyId}

The crown jewel of the BFF: cryptographically signs a handoff and returns the redirect URL into bff-tenant-booking-service.

13.1 Request

POST /bff/consumer/v1/handoff/tnt_01H.../ppt_01H... HTTP/1.1
Cookie: gms_id=gms_01H...
X-Idempotency-Key: 01H8YN7QF9RTBRZG4F8Y5CK4MV
Accept-Language: ps-AF
X-Currency: AFN
Content-Type: application/json

{
"dates": { "checkIn": "2026-05-12", "checkOut": "2026-05-15" },
"occupancy": { "adults": 2, "children": 0, "rooms": 1 },
"currency": "AFN",
"locale": "ps-AF",
"sourceCampaign": { "source": "google", "medium": "cpc", "campaign": "spring-2026" }
}

13.2 Response (201)

{
"data": {
"handoffId": "bhd_01H8YN7QV4D8KZ4F8Y5CK4MV3X",
"redirectUrl": "https://kabul-grand-hotel.melmastoon.ghasi.io/book?h=v1.eyJpZCI6ImJoZF8...&sig=4f3a...",
"expiresAt": "2026-04-23T09:44:22.041Z",
"ttlSeconds": 1800,
"tenantSlug": "kabul-grand-hotel"
}
}

13.3 Token format

v1.<base64url(payload)>.<base64url(hmac-sha256(secret, canonicalString))>

payload is the JSON BookingHandoff fields (excluding hmac, consumed, consumedAt, consumedByBffInstance). canonicalString is defined in DOMAIN_MODEL §2.4. The hmacKeyId is included in the payload so the receiving BFF can pick the right key from Secret Manager.

13.4 Errors

CodeHTTPWhen
MELMASTOON.BFF.CONSUMER.TENANT_SUSPENDED403Target tenant suspended
MELMASTOON.GENERAL.RESOURCE_NOT_FOUND404Tenant or property unknown
MELMASTOON.BFF.CONSUMER.PROPERTY_TENANT_MISMATCH422Property does not belong to the path tenant
MELMASTOON.GENERAL.VALIDATION_FAILED422Invalid dates / occupancy
MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED409Same Idempotency-Key with different body
MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT429Bot detector tripped

14. GET /session

{
"data": {
"guestSessionId": "gms_01H...",
"createdAt": "2026-04-23T09:00:00.000Z",
"lastSeenAt": "2026-04-23T09:14:22.041Z",
"localePreference": "ps-AF",
"currencyPreference": "AFN",
"wishlistCount": 4,
"recentlyViewedCount": 12,
"consent": { "telemetry": true, "marketing": false }
}
}

15. POST /session/locale

{ "locale": "fa-AF" }

200 OK, returns the same GET /session body with the new value. Emits melmastoon.bff.consumer.locale.changed.v1.

16. POST /session/currency

{ "currency": "USD" }

200 OK. Emits melmastoon.bff.consumer.currency.changed.v1.

17. POST /session/clear

204 No Content. Purges GuestSession from Memorystore, deletes wishlist_anonymous rows for the cookie, expires the cookie via Set-Cookie: gms_id=; Max-Age=0. Used by the consent banner's "Clear my data" button (GDPR Article 17 satisfaction at the BFF tier — does not propagate beyond this BFF since no PII reaches downstream services from this surface).

18. POST /telemetry/page-view

Client-side hook to register page views the BFF cannot infer (e.g., SPA route changes inside the meta web app).

{
"path": "/search",
"tenantId": null,
"propertyId": null,
"referer": "https://google.com",
"viewedAt": "2026-04-23T09:14:22.041Z"
}

202 Accepted. The server timestamps server-side regardless and uses the client viewedAt only as a hint. Sampled at 100% but rate-limited per session at 60/min.

19. POST /telemetry/click

{
"kind": "listing-card",
"propertyId": "ppt_01H...",
"tenantId": "tnt_01H...",
"searchSessionId": "srs_01H...",
"position": 3,
"metadata": { "page": 1, "sortKey": "recommended" }
}

202 Accepted. Emits melmastoon.bff.consumer.click.recorded.v1.

20. Health endpoints

GET /health/live returns 200 { "status":"live" }.

GET /health/ready checks Memorystore (PING), Postgres (SELECT 1), and the upstream circuit-breaker state for search-aggregation-service, pricing-service, property-service, theme-config-service. Returns 200 { status:"ready", checks:[...] } or 503 with the breakdown.

21. Pagination, sorting, filtering

  • Pagination: page.limit (max 50) + page.offset (max 1000). Cursor pagination is not offered for /search because the cross-tenant projection re-ranks per request.
  • Sorting: sortKey per DOMAIN_MODEL §2.2.
  • Filtering: nested filters object on /search; pass-through to search-aggregation-service.

22. Error response shape

Per ERROR_CODES. Example:

{
"error": {
"type": "https://errors.melmastoon.ghasi.io/bff-consumer/handoff-replayed",
"code": "MELMASTOON.BFF.CONSUMER.HANDOFF_REPLAYED",
"title": "Handoff token already consumed",
"status": 409,
"detail": "This handoff link was already used. Please return to the search results and start a new booking.",
"instance": "/bff/consumer/v1/handoff/tnt_.../ppt_...",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"requestId": "req_01H8YN7QF9RTBRZG4F8Y5CK4MV",
"tenantId": null,
"retriable": false,
"retryAfter": null,
"userMessageKey": "errors.bff_consumer.handoff_replayed",
"docUrl": "https://docs.melmastoon.ghasi.io/errors/bff-consumer/handoff-replayed",
"runbook": "https://runbooks.melmastoon.ghasi.io/bff-consumer/handoff-replayed"
}
}

23. Rate-limit table

RoutePer cookiePer IPPer fingerprint
POST /search600/min1200/min600/min
POST /search/map600/min1200/min600/min
GET /hotels/*1200/min2400/min1200/min
POST /handoff/*30/min60/min30/min
POST /wishlist60/min120/min60/min
POST /session/*30/min60/min30/min
POST /telemetry/*600/min1200/min600/min

Buckets are token-bucket (Cloud Armor at edge + Memorystore at app-tier for fine-grained).

24. CDN integration

  • /search, /search/map, /hotels/{id}, /hotels/{id}/availability, /facets are CDN-cacheable. The BFF sets Vary: Accept-Language, X-Currency, Accept-Encoding.
  • gms_id cookie is not part of the cache key for these GETs — the response is cookie-independent (session-aware fields like wishlistCount are loaded client-side via /session).
  • Mutating routes set Cache-Control: no-store.
  • Edge purge via /admin/cache/purge?tags=... (internal-only; gated by Cloud IAM service-account token).

25. Content negotiation

JSON only. Accept: application/json assumed; non-JSON returns 406 Not Acceptable. Compression: Accept-Encoding: br, gzip honored; default br if client supports.

26. Cross-region behavior

  • The BFF is regional (Cloud Run multi-region with asia-south1 primary, europe-west1 failover). The cookie is single-region-bound during normal operation; failover replicates Memorystore via Redis Replication and Postgres via Cloud SQL HA replicas.
  • When responding to a request whose target property is in a far region, the response includes crossRegionDelivery: true on the listing card. The handoff target tenant slug routes to the tenant's home region; we do not pre-warm cross-region availability.

27. Example client code (TypeScript, web)

import { z } from 'zod';

const SearchResponse = z.object({ /* schema mirrors §5.2 */ });

export async function searchHotels(query: SearchQuery, signal: AbortSignal) {
const res = await fetch('/bff/consumer/v1/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Request-Id': newUlid(),
'Accept-Language': navigator.language,
},
body: JSON.stringify(query),
signal,
});
if (!res.ok) throw await mapError(res);
return SearchResponse.parse(await res.json());
}

28. Changelog policy

Breaking changes require a major-version bump (/v1/v2) with a 90-day deprecation overlap and a Deprecation header on /v1. Additive fields (new optional response fields, new sort keys) are minor and shipped under /v1.