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 anAuthorization: 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
| Header | Required | Notes |
|---|---|---|
Cookie: gms_id=<ulid> | first request optional, then required | Cross-tenant guest session |
X-Request-Id | recommended | Generated by client; gateway fills if absent |
Accept-Language | recommended | BCP-47; resolves locale |
X-Currency | optional | ISO 4217; overrides session preference |
X-Idempotency-Key | required on POST/DELETE mutating routes | ULID |
traceparent | auto | W3C trace context |
X-Recaptcha-Token | conditional | When server returned MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT |
X-Campaign-Source / X-Campaign-Medium / X-Campaign-Name | optional | UTM passthrough; persisted on session |
3. Common response headers
| Header | Notes |
|---|---|
Set-Cookie: gms_id=<ulid>; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000 | On bootstrap |
X-Request-Id | Echoed |
X-Cache: HIT / MISS / STALE / BYPASS | Cache disposition |
X-Cache-Key | When Debug-Mode: true and request is from an allow-listed dev IP |
Cache-Control | Per-route (see below) |
Vary | Accept-Language, X-Currency, Accept-Encoding |
RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset | RFC 9239 |
traceparent | Echoed |
4. Endpoint catalogue
| Method | Path | Purpose | Cache | Idempotent |
|---|---|---|---|---|
POST | /search | Ranked list view | edge: 15 s; app: 60 s | yes |
POST | /search/map | Bounding-box pins | edge: 15 s; app: 60 s | yes |
GET | /hotels/{propertyId} | Hotel detail view-model | edge: 60 s; app: 5 min | yes |
GET | /hotels/{propertyId}/availability | Light availability snapshot | edge: 30 s; app: 60 s | yes |
GET | /facets | Static + dynamic facet catalog | edge: 1 h; app: 1 h | yes |
POST | /wishlist | Add to wishlist | none | yes (Idempotency-Key) |
DELETE | /wishlist/{propertyId} | Remove from wishlist | none | yes |
GET | /wishlist | Read wishlist | none | yes |
POST | /handoff/{tenantId}/{propertyId} | Mint signed handoff link | none | yes (Idempotency-Key) |
GET | /session | Read sanitized session | none | yes |
POST | /session/locale | Set locale preference | none | yes |
POST | /session/currency | Set currency preference | none | yes |
POST | /session/clear | Clear (purge) session | none | yes |
POST | /telemetry/page-view | Client-emitted page-view | none | yes |
POST | /telemetry/click | Click event | none | yes |
GET | /health/live | Liveness | none | yes |
GET | /health/ready | Readiness (checks Memorystore + Postgres + upstream circuit-breakers) | none | yes |
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
| Code | HTTP | When |
|---|---|---|
MELMASTOON.GENERAL.VALIDATION_FAILED | 422 | Invalid date range, occupancy, geo |
MELMASTOON.BFF.CONSUMER.LOCALE_NOT_SUPPORTED | 422 | Accept-Language resolves to unsupported tag |
MELMASTOON.BFF.CONSUMER.CURRENCY_NOT_SUPPORTED | 422 | X-Currency not in {AFN, USD, EUR, IRR, PKR, AED, GBP} |
MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT | 429 | Bot-detector threshold exceeded; carries Retry-After and X-Recaptcha-SiteKey |
MELMASTOON.GENERAL.RATE_LIMITED | 429 | Per-fingerprint or per-IP bucket exhausted |
MELMASTOON.BFF.CONSUMER.UPSTREAM_BUDGET_EXCEEDED | 504 | Per-request fanout budget hit |
MELMASTOON.SEARCH_AGGREGATION.UPSTREAM_UNAVAILABLE | 502 | Aggregation 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®ion=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
| Code | HTTP | When |
|---|---|---|
MELMASTOON.BFF.CONSUMER.TENANT_SUSPENDED | 403 | Target tenant suspended |
MELMASTOON.GENERAL.RESOURCE_NOT_FOUND | 404 | Tenant or property unknown |
MELMASTOON.BFF.CONSUMER.PROPERTY_TENANT_MISMATCH | 422 | Property does not belong to the path tenant |
MELMASTOON.GENERAL.VALIDATION_FAILED | 422 | Invalid dates / occupancy |
MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED | 409 | Same Idempotency-Key with different body |
MELMASTOON.BFF.CONSUMER.SUSPECTED_BOT | 429 | Bot 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/searchbecause the cross-tenant projection re-ranks per request. - Sorting:
sortKeyper DOMAIN_MODEL §2.2. - Filtering: nested
filtersobject on/search; pass-through tosearch-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
| Route | Per cookie | Per IP | Per fingerprint |
|---|---|---|---|
POST /search | 600/min | 1200/min | 600/min |
POST /search/map | 600/min | 1200/min | 600/min |
GET /hotels/* | 1200/min | 2400/min | 1200/min |
POST /handoff/* | 30/min | 60/min | 30/min |
POST /wishlist | 60/min | 120/min | 60/min |
POST /session/* | 30/min | 60/min | 30/min |
POST /telemetry/* | 600/min | 1200/min | 600/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,/facetsare CDN-cacheable. The BFF setsVary: Accept-Language, X-Currency, Accept-Encoding.gms_idcookie is not part of the cache key for these GETs — the response is cookie-independent (session-aware fields likewishlistCountare 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-south1primary,europe-west1failover). 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: trueon 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.