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
| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer <jwt> | inbound | required on all non-health routes |
X-Tenant-Id: tnt_<ULID> | inbound | must equal JWT tenant_id claim |
X-Request-Id: <ULID> | inbound | client-supplied, echoed back |
Idempotency-Key: <ULID> | inbound | required on all POST / PUT / PATCH / DELETE |
If-Match: <version> | inbound | required on PATCH / PUT / state-changing POST |
Accept-Language: <bcp-47> | inbound | drives userMessageKey resolution in errors |
traceparent (W3C) | inbound | propagated to logs & events |
ETag: <version> | outbound | reflects aggregate version |
Cache-Control | outbound | private, 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/preview → 200 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=falseGET /api/v1/properties/{id}/room-types/{rmt}PATCH /api/v1/properties/{id}/room-types/{rmt}(requiresIf-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=50GET /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 / buildingGET /api/v1/properties/{id}/room-groupsPATCH /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
| Route | Purpose |
|---|---|
GET /healthz | Liveness — process responsive |
GET /readyz | Readiness — DB + Pub/Sub + Redis OK |
GET /metrics | Prometheus 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 surface | Window | Cap |
|---|---|---|
| Backoffice (per user) | 1 min | 600 req |
| Tenant booking BFF | 1 min | 1200 req |
| Consumer BFF (geo) | 1 min | 1800 req |
| Internal sync | 1 min | 6000 req |
Bursting allowed up to 2× cap for 10 s. Exceed → 429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After.