Skip to main content

property-service — API Contracts

Companion: APPLICATION_LOGIC · DOMAIN_MODEL · 05 API Design · Error Codes

All routes follow docs/05-api-design.md: JSON over HTTPS, ULID IDs (<prefix>_<ULID>), X-Tenant-Id header (verified against JWT claim), Idempotency-Key on writes, If-Match: <version> on updates, RFC 9457 errors. Base URL https://api.melmastoon.ghasi.io. Internal sync/cache routes use the prefix /internal/v1 and require an mTLS-authenticated service account.

OpenAPI source of truth: services/property-service/contracts/openapi.yaml (generated from controllers + DTOs).

1. Common headers

HeaderDirectionNotes
Authorization: Bearer <jwt>inboundrequired on all non-health routes
X-Tenant-Id: tnt_<ULID>inboundmust equal JWT tenant_id claim
X-Request-Id: <ULID>inboundclient-supplied, echoed back
Idempotency-Key: <ULID>inboundrequired on all POST / PUT / PATCH / DELETE
If-Match: <version>inboundrequired on PATCH / PUT / state-changing POST
Accept-Language: <bcp-47>inbounddrives userMessageKey resolution in errors
traceparent (W3C)inboundpropagated to logs & events
ETag: <version>outboundreflects aggregate version
Cache-Controloutboundprivate, max-age=<ttl> on hot reads

2. Properties

2.1 Create property

POST /api/v1/properties

{
"slug": "ghasi-house-kabul",
"name": {
"default": "en",
"values": {
"en": "Ghasi House Kabul",
"ps": "غاسي ښارگوټی کابل",
"fa": "خانه غاسی کابل"
}
},
"description": {
"default": "en",
"values": {
"en": "A 30-room boutique guesthouse in the heart of Kabul.",
"ps": "په کابل کې د ۳۰-کوټو یوه کوچنۍ ښارگوټی.",
"fa": "یک مهمان‌خانه ۳۰ اتاقه در قلب کابل."
}
},
"address": {
"line1": "House 12, Street 4, Wazir Akbar Khan",
"nativeScriptLine1": "کور ۱۲، څلورمه کوڅه، وزیر اکبر خان",
"city": "Kabul",
"countryIso2": "AF"
},
"geo": { "lat": 34.5328, "lng": 69.1727, "source": "manual" },
"timezone": "Asia/Kabul",
"starRating": 3,
"enabledLocales": ["en", "ps", "fa"],
"defaultLocale": "en"
}

201 Created returns the full PropertyDto.

2.2 List properties

GET /api/v1/properties?status=published&country=AF&page=1&pageSize=20&sort=-updatedAt

Cursor pagination supported via cursor=<opaque>; offset pagination via page & pageSize (cap 100).

Response:

{
"items": [ { "id": "ppt_01H...", "slug": "...", "status": "published", "name": { "default": "en", "values": { "en": "..." } } } ],
"page": { "current": 1, "size": 20, "total": 47, "nextCursor": "eyJ..." }
}

2.3 Get property

GET /api/v1/properties/{id}200 PropertyDto. Headers ETag, Cache-Control: private, max-age=60.

PropertyDto shape:

{
"id": "ppt_01H...",
"tenantId": "tnt_01H...",
"slug": "ghasi-house-kabul",
"name": { "default": "en", "values": { "en": "...", "ps": "...", "fa": "..." } },
"description": { "default": "en", "values": { "en": "..." } },
"address": { "line1": "...", "city": "Kabul", "countryIso2": "AF", "nativeScriptLine1": "..." },
"geo": { "lat": 34.5328, "lng": 69.1727, "source": "manual" },
"timezone": "Asia/Kabul",
"starRating": 3,
"status": "draft",
"enabledLocales": ["en", "ps", "fa"],
"defaultLocale": "en",
"amenities": ["wifi", "halal_kitchen", "prayer_room", "generator_backup", "hot_water_24h"],
"counts": { "rooms": 30, "roomTypes": 5, "photos": 12 },
"publishedAt": null,
"createdAt": "2026-04-22T08:14:11Z",
"updatedAt": "2026-04-22T08:14:11Z",
"version": 1
}

2.4 Update property metadata

PATCH /api/v1/properties/{id} (requires If-Match)

Editable fields: name, description, address, geo, timezone, starRating, enabledLocales, defaultLocale, slug (only when status=draft).

2.5 Publish / unpublish

POST /api/v1/properties/{id}/publish — body empty; emits property.published.v1.

POST /api/v1/properties/{id}/unpublish:

{ "reason": "tenant_request", "note": "Closed for renovation" }

reason ∈ { "tenant_request", "compliance", "incident", "cascade_tenant_deleted" }.

GET /api/v1/properties/{id}/publish/preview200 returns publish dry-run:

{ "eligible": false, "violations": [
{ "code": "NO_ROOMS", "detail": "No active rooms exist" },
{ "code": "NO_PHOTO", "detail": "No ready photo at scope=property" }
] }

2.6 Delete (archive)

DELETE /api/v1/properties/{id} — soft delete; cascades unpublish + child archive.

3. Room types

3.1 Create room type

POST /api/v1/properties/{id}/room-types

{
"code": "FAMILY_4",
"name": { "default": "en", "values": { "en": "Family Room (4)", "fa": "اتاق خانوادگی" } },
"description": { "default": "en", "values": { "en": "Family room with one king + two single beds." } },
"bedConfig": { "primary": "king", "extras": ["single", "single"] },
"maxOccupancy": 4,
"amenities": ["wifi", "air_conditioning", "halal_kitchen", "family_room"],
"basePriceHintMicro": "120000000",
"baseCurrency": "AFN"
}

3.2 List / Get / Update / Archive

  • GET /api/v1/properties/{id}/room-types?includeArchived=false
  • GET /api/v1/properties/{id}/room-types/{rmt}
  • PATCH /api/v1/properties/{id}/room-types/{rmt} (requires If-Match)
  • DELETE /api/v1/properties/{id}/room-types/{rmt} (archive; blocked if active rooms exist → MELMASTOON.PROPERTY.ROOM_TYPE_INVALID)

4. Rooms

4.1 Create single room

POST /api/v1/properties/{id}/rooms

{
"roomTypeId": "rmt_01H...",
"number": "301",
"floor": 3,
"roomGroupId": "rgp_01H...",
"features": ["balcony", "city_view"],
"accessibility": { "wheelchair": false, "rollInShower": false, "hearingAids": false, "visualAids": false }
}

4.2 Bulk create rooms

POST /api/v1/properties/{id}/rooms/bulk (max 200 rows; all-or-nothing transaction)

{
"items": [
{ "roomTypeId": "rmt_01H...", "number": "301", "floor": 3 },
{ "roomTypeId": "rmt_01H...", "number": "302", "floor": 3 },
{ "roomTypeId": "rmt_01H...", "number": "303", "floor": 3, "accessibility": { "wheelchair": true, "rollInShower": true, "hearingAids": false, "visualAids": false } }
]
}

200 returns the full list with assigned IDs; 422 returns the failing batch with errors[]:

{
"error": {
"code": "MELMASTOON.GENERAL.VALIDATION_FAILED",
"title": "Bulk room create failed",
"status": 422,
"errors": [
{ "field": "items[1].number", "code": "MELMASTOON.PROPERTY.ROOM_NUMBER_DUPLICATE" }
]
}
}

4.3 List / Get / Update

  • GET /api/v1/properties/{id}/rooms?status=active&floor=3&groupId=rgp_…&page=1&pageSize=50
  • GET /api/v1/properties/{id}/rooms/{rmu}
  • PATCH /api/v1/properties/{id}/rooms/{rmu} (editable: number, floor, roomGroupId, features, accessibility, roomTypeId)

4.4 OOO / RTS

POST /api/v1/properties/{id}/rooms/{rmu}/take-out-of-order

{ "reason": "maintenance", "until": "2026-05-01T00:00:00Z", "note": "AC unit replacement" }

reason ∈ { "housekeeping", "maintenance", "manual", "incident" }. If active reservations overlap window → 409 MELMASTOON.PROPERTY.ROOM_OCCUPIED.

POST /api/v1/properties/{id}/rooms/{rmu}/return-to-service

{ "note": "WO MNT-01H... closed" }

4.5 Archive

DELETE /api/v1/properties/{id}/rooms/{rmu} — blocked if active reservations exist.

5. Photos

5.1 Request signed upload

POST /api/v1/properties/{id}/photos

{
"scope": { "kind": "property" },
"contentType": "image/jpeg",
"bytes": 384210,
"altText": { "default": "en", "values": { "en": "Lobby at sunset" } },
"tags": []
}

201 returns:

{
"uploadUrl": "https://storage.googleapis.com/melmastoon-media-prod/...?X-Goog-Signature=...",
"uploadHeaders": { "x-goog-content-length-range": "0,15728640" },
"storageKey": "tenants/tnt_.../properties/ppt_.../photos/2026/04/22/01H...jpg",
"expiresAt": "2026-04-22T08:24:11Z"
}

5.2 Register photo (after client uploads bytes)

POST /api/v1/properties/{id}/photos/register

{ "storageKey": "tenants/tnt_.../properties/ppt_.../photos/2026/04/22/01H...jpg", "scope": { "kind": "property" }, "altText": { "default": "en", "values": { "en": "Lobby at sunset" } }, "tags": ["lobby"], "order": 0 }

201 returns PhotoDto with status='uploaded'. The photo flips to ready once the virus scan event arrives.

5.3 Reorder photos

PATCH /api/v1/properties/{id}/photos/order

{
"scope": { "kind": "property" },
"order": [
{ "photoId": "pht_01H...", "position": 0 },
{ "photoId": "pht_01H...", "position": 1 },
{ "photoId": "pht_01H...", "position": 2 }
]
}

Full ordering vector required (no partial reorders).

5.4 Delete photo

DELETE /api/v1/properties/{id}/photos/{pht} — soft delete (status archived); emits photo.removed.v1. The byte object in file-storage-service is retention-managed there.

6. Amenities

6.1 Get canonical registry

GET /api/v1/amenities — returns the canonical list with i18n labels (cached aggressively, public).

{
"items": [
{ "code": "wifi", "labels": { "en": "Free Wi-Fi", "ps": "وړیا وای فای", "fa": "وای‌فای رایگان" }, "regional": false },
{ "code": "halal_kitchen", "labels": { "en": "Halal kitchen", "ps": "حلال پخلنځی", "fa": "آشپزخانه حلال" }, "regional": true },
{ "code": "prayer_room", "labels": { "en": "Prayer room", "ps": "د لمانځه خونه", "fa": "نمازخانه" }, "regional": true }
]
}

6.2 Set per-property amenities

PUT /api/v1/properties/{id}/amenities

{ "amenities": ["wifi", "parking", "breakfast", "halal_kitchen", "prayer_room", "generator_backup", "hot_water_24h"] }

Unknown code → 422 MELMASTOON.PROPERTY.AMENITY_UNKNOWN.

7. Policies

7.1 Get resolved policies

GET /api/v1/properties/{id}/policies returns tenant defaults merged with property overrides:

{
"resolved": {
"check_in_time": { "value": "14:00", "source": "property" },
"check_out_time": { "value": "11:00", "source": "tenant" },
"cancellation": {
"value": { "freeUntilHoursBeforeArrival": 24, "lateFeeMicro": "5000000", "currency": "AFN" },
"source": "property"
},
"child": { "value": { "underAgeFreeUpTo": 6, "extraBedFeeMicro": "0" }, "source": "tenant" },
"smoking": { "value": "non_smoking", "source": "tenant" },
"pets": { "value": "not_allowed", "source": "tenant" },
"deposit": { "value": { "kind": "percent_of_total", "amount": 25 }, "source": "property" },
"id_required": { "value": true, "source": "tenant" },
"prayer_room_access": { "value": "always", "source": "property" },
"women_only_floor": { "value": { "floors": [4] }, "source": "property" }
},
"overrides": [
{ "kind": "check_in_time", "value": "14:00" },
{ "kind": "cancellation", "value": { "freeUntilHoursBeforeArrival": 24, "lateFeeMicro": "5000000", "currency": "AFN" } }
]
}

7.2 Upsert / remove an override

PUT /api/v1/properties/{id}/policies/overrides/{kind}

{ "value": "14:30", "effectiveFrom": "2026-05-01T00:00:00Z" }

DELETE /api/v1/properties/{id}/policies/overrides/{kind} removes the override.

8. Geo

8.1 Bounding box search (consumer / tenant booking)

GET /api/v1/properties/geo/search?bbox=68.9,34.4,69.4,34.7&amenities=halal_kitchen,prayer_room&minStars=3&page=1&pageSize=50

bbox is minLng,minLat,maxLng,maxLat. Bbox area capped at 5000 km² (MELMASTOON.SEARCH.GEO_OUT_OF_BOUNDS otherwise).

Response items expose only published properties:

{
"items": [
{ "id": "ppt_01H...", "slug": "ghasi-house-kabul", "name": { "default": "en", "values": { "en": "Ghasi House Kabul" } }, "geo": { "lat": 34.5328, "lng": 69.1727 }, "starRating": 3, "amenities": ["halal_kitchen", "prayer_room"], "coverPhotoUrl": "https://media.melmastoon.ghasi.io/..." }
],
"page": { "current": 1, "size": 50, "total": 7 }
}

8.2 Nearby

GET /api/v1/properties/geo/nearby?lat=34.5&lng=69.1&radiusKm=5

radiusKm capped at 100 km.

9. Room groups

  • POST /api/v1/properties/{id}/room-groups — create floor / wing / building
  • GET /api/v1/properties/{id}/room-groups
  • PATCH /api/v1/properties/{id}/room-groups/{rgp}
  • DELETE /api/v1/properties/{id}/room-groups/{rgp} (blocked if rooms reference it)

10. Internal sync endpoints

GET /internal/v1/property/changes?tenantId=tnt_…&since=<cursor>&limit=500

Returns batched aggregate snapshots ordered by commit position; mTLS only.

{
"changes": [
{ "aggregate": "property", "id": "ppt_...", "version": 7, "snapshot": { /* PropertyDto */ }, "tombstone": false, "cursor": "00000000007/00012" },
{ "aggregate": "room", "id": "rmu_...", "version": 3, "snapshot": { /* RoomDto */ }, "tombstone": false, "cursor": "00000000007/00013" }
],
"nextCursor": "00000000007/00013",
"hasMore": false
}

POST /internal/v1/property/sync/push is closed; offline desktop pushes go through bff-backoffice-service → here, gated by per-aggregate conflict policy (see SYNC_CONTRACT).

11. Health

RoutePurpose
GET /healthzLiveness — process responsive
GET /readyzReadiness — DB + Pub/Sub + Redis OK
GET /metricsPrometheus exposition (only on internal port 9090)

12. OpenAPI excerpt (publish endpoint)

paths:
/api/v1/properties/{id}/publish:
post:
operationId: publishProperty
summary: Publish a draft property
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/IfMatch'
- in: path
name: id
required: true
schema: { type: string, pattern: '^ppt_[0-7][0-9A-HJKMNP-TV-Z]{25}$' }
responses:
'200':
description: Property published
content:
application/json:
schema: { $ref: '#/components/schemas/PropertyDto' }
'409':
description: Publish blocked
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
noRooms:
value: { error: { code: 'MELMASTOON.PROPERTY.NO_ROOMS_FOR_PUBLISH', status: 409, title: 'Publish blocked: no rooms', detail: 'A published property must have at least one active room.', retriable: false, traceId: '...', requestId: '...' } }
noGeo:
value: { error: { code: 'MELMASTOON.PROPERTY.GEO_REQUIRED_FOR_PUBLISH', status: 409, title: 'Publish blocked: geo missing', detail: 'A published property must have geo coordinates.', retriable: false } }
'412':
description: Version mismatch
'404':
description: Property not found

13. Rate limits

Caller surfaceWindowCap
Backoffice (per user)1 min600 req
Tenant booking BFF1 min1200 req
Consumer BFF (geo)1 min1800 req
Internal sync1 min6000 req

Bursting allowed up to 2× cap for 10 s. Exceed → 429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After.