API_CONTRACTS — reservation-service
Sibling: APPLICATION_LOGIC · DOMAIN_MODEL · SECURITY_MODEL · EVENT_SCHEMAS
Strategic anchors: 05 API Design · standards/NAMING · standards/ERROR_CODES
All endpoints are versioned at /api/v1/reservations. Request/response bodies are JSON; errors are RFC 7807 Problem+JSON. Money is serialized as { "amountMicro": "12500000", "currency": "USD" } (string for bigint over JSON). Timestamps are ISO-8601 UTC unless explicitly noted as a property-local date (YYYY-MM-DD). All mutating endpoints accept Idempotency-Key and propagate traceparent.
This service is fronted by bff-tenant-booking-service (guest funnel) and bff-backoffice-service (front-desk staff). Direct client traffic is not allowed; the BFFs hold the only outbound IAM principals authorized to call us.
1. Common request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <jwt> | yes | Issued by iam-service for staff; or anonymous booking session token for guests |
X-Tenant-Id: tnt_… | yes | Cross-checked against JWT; mismatch → 403 MELMASTOON.TENANT.MISMATCH |
X-Property-Id: ppt_… | for staff endpoints | Validated against staff property access |
Idempotency-Key: <ULID> | yes for POST/PATCH | 24h dedupe window |
Accept-Language: <bcp47> | recommended | Drives Problem+JSON title localization |
traceparent: 00-…-…-01 | yes (set by gateway) | W3C trace propagation |
2. Common error envelope (RFC 7807 + extensions)
{
"type": "https://errors.melmastoon.com/RESERVATION/OVERBOOKING_BLOCKED",
"title": "Selected room is no longer available for the chosen dates.",
"status": 409,
"code": "MELMASTOON.RESERVATION.OVERBOOKING_BLOCKED",
"detail": "Inventory rejected the hold for room rmu_01H3Z… on 2026-04-25..2026-04-27.",
"instance": "/api/v1/reservations/holds",
"traceId": "01H3Z4WK7…",
"tenantId": "tnt_01H…",
"violations": [
{ "field": "items[0].roomTypeId", "rule": "no_inventory" }
]
}
Common status mapping:
| Status | Used for |
|---|---|
400 | validation, malformed request |
401 | missing/invalid JWT |
403 | tenant mismatch, RBAC/ABAC denial |
404 | not found (or RLS-hidden) |
409 | OCC, illegal transition, overbooking, hold limit, saga in flight |
410 | quote/hold expired |
422 | domain invariant violation that isn't a transient conflict |
429 | rate limit |
500 | unhandled |
503 | downstream (pricing, inventory, payment, lock) unavailable |
3. Endpoints
3.1 POST /api/v1/reservations/quotes
Request a price quote with TTL.
Request
{
"propertyId": "ppt_01H3Z3GXSE7Y8B5RBQ8H6JQK7Z",
"stayWindow": { "start": "2026-04-25", "end": "2026-04-28" },
"items": [
{ "roomTypeId": "rmt_01H3Z…SUITE", "occupants": { "adults": 2, "children": 1, "infants": 0 } },
{ "roomTypeId": "rmt_01H3Z…TWIN", "occupants": { "adults": 2, "children": 0, "infants": 0 } }
],
"ratePlanIdHint": "rate_01H…BAR",
"channel": "direct",
"locale": "ps-AF",
"promoCode": "WELCOME10"
}
Response 200
{
"quoteId": "qte_01H3Z…",
"ttlSeconds": 900,
"expiresAt": "2026-04-22T18:46:00Z",
"items": [
{
"roomTypeId": "rmt_01H3Z…SUITE",
"nightlyBreakdown": [
{ "date": "2026-04-25", "rate": { "amountMicro": "120000000", "currency": "USD" }, "rateCode": "BAR", "taxMicro": "12000000" },
{ "date": "2026-04-26", "rate": { "amountMicro": "120000000", "currency": "USD" }, "rateCode": "BAR", "taxMicro": "12000000" },
{ "date": "2026-04-27", "rate": { "amountMicro": "140000000", "currency": "USD" }, "rateCode": "BAR", "taxMicro": "14000000" }
],
"itemSubtotal": { "amountMicro": "380000000", "currency": "USD" }
}
],
"totals": {
"subtotal": { "amountMicro": "560000000", "currency": "USD" },
"discountTotal": { "amountMicro": "56000000", "currency": "USD" },
"taxTotal": { "amountMicro": "55000000", "currency": "USD" },
"grandTotal": { "amountMicro": "559000000", "currency": "USD" },
"inTenantCurrency": { "amountMicro": "23478000000000", "currency": "IRR" }
},
"fxSnapshot": {
"base": "USD", "quote": "IRR", "rate": 42000.0,
"source": "pricing_service", "capturedAt": "2026-04-22T18:31:00Z"
}
}
Errors: 400 validation, 404 PROPERTY_NOT_FOUND, 503 PRICING_UNAVAILABLE, 503 INVENTORY_UNAVAILABLE.
3.2 POST /api/v1/reservations/holds
Place a 10-minute hold and create the payment intent.
Request
{
"quoteId": "qte_01H3Z…",
"primaryGuest": {
"fullName": { "given": "اسماعیل", "family": "خان", "scriptHint": "pashto" },
"email": "ismail.khan@example.af",
"phoneE164": "+93700000000",
"locale": "ps-AF",
"documentRef": { "type": "national_id", "numberLast4": "8421" }
},
"additionalGuests": [
{ "fullName": { "given": "Sadia", "family": "Khan", "scriptHint": "latin" }, "ageBand": "adult" },
{ "fullName": { "given": "Yusuf", "family": "Khan", "scriptHint": "latin" }, "ageBand": "child", "isChildWithoutId": true }
],
"specialRequests": [
{ "freeText": "د حلال خواړو غوښتنه؛ غونډلې کوټه که شونې وي.", "locale": "ps-AF" }
],
"paymentMethodHint": "card"
}
Response 201
{
"reservation": {
"id": "rsv_01H3Z4WK7TS…",
"reservationCode": "GM-9F4K2C",
"status": "held",
"holdExpiresAt": "2026-04-22T18:41:00Z",
"stayWindow": { "start": "2026-04-25", "end": "2026-04-28", "nights": 3 },
"totals": { "grandTotal": { "amountMicro": "559000000", "currency": "USD" } },
"channel": { "channel": "direct", "capturedAt": "2026-04-22T18:31:00Z" }
},
"paymentIntent": {
"paymentIntentId": "pyi_01H3Z…",
"clientSecret": "ps_live_…opaque…",
"provider": "card",
"amount": { "amountMicro": "559000000", "currency": "USD" }
}
}
Errors: 410 QUOTE_EXPIRED, 409 OVERBOOKING_BLOCKED, 409 HOLD_LIMIT_EXCEEDED, 503 PAYMENT_GATEWAY_UNAVAILABLE.
3.3 POST /api/v1/reservations
Create + confirm in one shot (used by walk-in flow that has already captured payment, or by cash-on-arrival paths). For the standard flow, use holds then wait for the payment webhook.
Request
{
"quoteId": "qte_01H3Z…",
"primaryGuest": { "...": "..." },
"paymentMethod": "cash_on_arrival",
"channel": "walk_in",
"actorStaffId": "stf_01H…"
}
Response 201 — same shape as GET /:id.
3.4 GET /api/v1/reservations/:reservationId
Read a reservation. RLS scopes to tenant; 404 if not visible.
Response 200 — full Reservation (DOMAIN_MODEL §2). Money fields stringified, dates property-local where stay-related, UTC otherwise.
3.5 PATCH /api/v1/reservations/:reservationId
Modify. The body must include a type discriminator from §2.5 of APPLICATION_LOGIC.
Request — date_change
{
"type": "date_change",
"newStayWindow": { "start": "2026-04-26", "end": "2026-04-30" },
"actorStaffId": "stf_01H…",
"reason": "Guest extended trip"
}
Request — room_change
{
"type": "room_change",
"items": [
{ "itemId": "01H3Z…", "newRoomTypeId": "rmt_…SUITE_PLUS" }
],
"actorStaffId": "stf_01H…"
}
Request — special_request_added
{
"type": "special_request_added",
"request": { "freeText": "Late check-in around 02:00 local", "locale": "en-US" }
}
Response 200 — updated Reservation.
Errors: 409 ILLEGAL_TRANSITION, 409 SAGA_IN_FLIGHT, 409 STALE_VERSION, 422 RETROACTIVE_DATE_CHANGE, 403 POLICY_VERSION_MISMATCH, 503 PRICING_UNAVAILABLE, 503 INVENTORY_UNAVAILABLE.
3.6 POST /api/v1/reservations/:reservationId/cancel
Request
{
"reason": "Guest changed plans",
"actorStaffId": "stf_01H…",
"policyOverride": null
}
Response 200
{
"reservation": { "id": "rsv_…", "status": "cancelled", "version": 7 },
"policyApplied": "tenant.cancellation_policy@v3",
"refundEligibility": { "amountMicro": "279500000", "currency": "USD" },
"penalty": { "amountMicro": "279500000", "currency": "USD" }
}
3.7 POST /api/v1/reservations/:reservationId/check-in
Request
{
"actorStaffId": "stf_01H…",
"earlyOverride": false,
"documentScans": [
{ "guestRef": "primary", "fileMediaId": "med_01H…" }
],
"issueKey": true
}
Response 200
{
"reservation": { "id": "rsv_…", "status": "checked_in", "version": 9 },
"key": { "credentialId": "key_01H…", "issuedAt": "2026-04-25T11:02:14Z", "deliveryChannels": ["sms","desktop"] },
"folio": { "folioId": "fol_01H…", "openedAt": "2026-04-25T11:02:11Z" }
}
Errors: 409 CHECK_IN_TOO_EARLY, 409 ROOM_NOT_READY, 503 LOCK_VENDOR_UNREACHABLE (response succeeds with requiresManualKey=true; not a 503).
3.8 POST /api/v1/reservations/:reservationId/check-out
Request
{
"actorStaffId": "stf_01H…",
"early": false,
"settleFolio": true
}
Response 200
{
"reservation": { "id": "rsv_…", "status": "checked_out", "version": 12 },
"folio": { "folioId": "fol_01H…", "balance": { "amountMicro": "0", "currency": "USD" }, "settledAt": "2026-04-28T10:14:00Z" },
"key": { "credentialId": "key_01H…", "revokedAt": "2026-04-28T10:14:01Z" }
}
3.9 POST /api/v1/reservations/:reservationId/walk-in
Combined create + check-in for a walk-in guest. Only callable by bff-backoffice-service.
Request
{
"propertyId": "ppt_01H…",
"stayWindow": { "start": "2026-04-22", "end": "2026-04-23" },
"items": [
{ "roomTypeId": "rmt_…TWIN", "occupants": { "adults": 2, "children": 0, "infants": 0 }, "preferredRoomId": "rmu_01H…105" }
],
"primaryGuest": {
"fullName": { "given": "Ali", "family": "Rahimi", "scriptHint": "latin" },
"phoneE164": "+93700000001",
"locale": "fa-AF"
},
"paymentMethod": "cash_on_arrival",
"channel": "walk_in",
"actorStaffId": "stf_01H…",
"deferKyc": false,
"issueKey": true
}
Response 201 — full reservation in checked_in state.
3.10 POST /api/v1/reservations/:reservationId/no-show
Request { "actorStaffId": "stf_…", "applyPolicy": true }
Response 200 — reservation in no_show, with penalty summary and emitted events.
3.11 POST /api/v1/reservations/:reservationId/special-requests
Request
{ "freeText": "وعن الإفطار: حلال فقط، شكرا.", "locale": "ar-SA" }
Response 201
{
"request": {
"id": "01H…",
"tags": ["halal", "other"],
"freeText": "…",
"locale": "ar-SA",
"source": "ai_parser"
}
}
3.12 GET /api/v1/reservations/by-guest/:guestId
Query: ?includeFuture=true&includeCancelled=false&page=1&pageSize=20
Response 200 — paginated cursor list with items[] and nextCursor.
3.13 POST /api/v1/reservations/groups/:groupId/split
Split a group reservation into independent ones.
Request
{
"splits": [
{ "items": ["item-1", "item-2"], "primaryGuest": { "...": "..." } },
{ "items": ["item-3"], "primaryGuest": { "...": "..." } }
],
"actorStaffId": "stf_01H…"
}
Response 200 — array of new reservation summaries plus the cancellation summary of the original.
3.14 POST /api/v1/reservations/merge
Request
{
"survivingReservationId": "rsv_A",
"mergedReservationId": "rsv_B",
"actorStaffId": "stf_01H…"
}
Response 200 — the surviving reservation in its updated form.
4. Internal endpoints (network-internal only; not via Kong)
These are reachable only from the VPC; Cloud Run service-to-service auth enforced.
| Method | Path | Purpose |
|---|---|---|
POST | /internal/events/payment.transaction.captured.v1 | Pub/Sub push handler |
POST | /internal/events/payment.transaction.failed.v1 | Pub/Sub push handler |
POST | /internal/events/payment.transaction.refunded.v1 | Pub/Sub push handler |
POST | /internal/events/inventory.allocation.committed.v1 | Pub/Sub push handler |
POST | /internal/events/inventory.allocation.failed.v1 | Pub/Sub push handler |
POST | /internal/events/lock.key.issued.v1 | Pub/Sub push handler |
POST | /internal/events/lock.key.failed.v1 | Pub/Sub push handler |
POST | /internal/events/tenant.settings.changed.v1 | Pub/Sub push handler (refresh hold-window default) |
POST | /internal/events/property.room.taken_out_of_order.v1 | Pub/Sub push handler (re-accommodation flow) |
POST | /internal/jobs/expire-holds | Cloud Scheduler invocation (every 30 s) |
GET | /internal/health | liveness + DB ping + Pub/Sub publisher health |
GET | /internal/ready | outbox relay lag + DB connection pool health |
5. Pagination, sorting, filtering
- All list endpoints use cursor-based pagination:
?cursor=<opaque>&pageSize=20(max 100). - Default sort is
createdAt desc;GET /by-guest/:guestIdsorts bystayWindow.start desc. - Filtering on
GET /by-guest:?status=confirmed,checked_in&channel=direct,walk_in&dateFrom=…&dateTo=….
6. Versioning & deprecation
- New optional fields may be added within
v1without notice. - Field removals, type changes, or required-field additions ship a new
v2resource group; both run in parallel for ≥ 8 weeks;Deprecation: <ISO>andSunset: <ISO>response headers warn callers.
7. Sample sequences
7.1 Happy path (direct booking, card)
1. POST /quotes → quoteId
2. POST /holds → reservation(held), paymentIntent.clientSecret
3. <client uses Stripe.js to confirm payment>
4. payment-gateway-service publishes payment.transaction.captured.v1
5. reservation-service ConfirmReservationUseCase fires → reservation.confirmed.v1
6. notification-service sends confirmation; lock-integration deferred until check-in
7. (At check-in time)
POST /:id/check-in → reservation(checked_in), key, folio
8. (At check-out time)
POST /:id/check-out → reservation(checked_out), folio settled, key revoked
7.2 Walk-in cash-on-arrival
1. POST /:id/walk-in (operator, idempotency-key per session)
Internally: quote → hold → cash_on_arrival confirm → check-in → key issue
2. Operator records cash via billing-service `POST /folios/:id/payments` later
3. payment-gateway-service publishes payment.transaction.captured.v1 (cash event)
4. reservation-service no state change (already confirmed); folio reflects payment
7.3 Modify dates with delta charge
1. PATCH /:id body { type: "date_change", newStayWindow: {…} }
2. Internal: re-quote → reallocate → delta = +1 night
3. payment-gateway-service charges delta via existing payment method (or new intent)
4. lock-integration-service updates key validity
5. Response: reservation with new stayWindow + modification audit entry
7.4 Cancel with partial refund
1. POST /:id/cancel body { reason: "guest_cancelled" }
2. Policy: 50% refund; reservation.cancelled.v1 emitted
3. inventory-service releases allocation
4. payment-gateway-service refunds half
5. lock-integration-service revokes key (no-op if not yet issued)
6. notification-service sends cancellation notice in guest locale
7.5 Group partial cancel
1. PATCH /:id body { type: "room_removed", itemId: "01H…" }
2. inventory-service releases that allocation
3. delta refund issued
4. Reservation.status remains 'confirmed'; modification audit row added
5. If all items removed, status flips to 'cancelled'