API_CONTRACTS — analytics-service
Sibling: APPLICATION_LOGIC · DOMAIN_MODEL · SECURITY_MODEL · platform anchors: docs/05 API Design, docs/standards/ERROR_CODES
All endpoints are mounted under the platform API gateway at /api/v1/analytics/*. Internal endpoints live at /internal/* and are reachable only from bff-*-service, sync-service, ai-orchestrator-service, and from Cloud Scheduler/Workflows via OIDC. Format is JSON unless stated otherwise.
0. Conventions
0.1 Common request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <jwt> | Yes | Verified via JWKS |
X-Tenant-Id: tnt_… | Yes | Cross-checked with JWT claim |
X-Correlation-Id | No (server-generated if absent) | propagated to Pub/Sub |
Idempotency-Key | Yes for non-idempotent mutations | UUID v4; persisted ≥ 24 h |
If-Match: <etag> | Yes for PUT/PATCH | OCC |
Accept-Language | No | falls back to tenant default |
0.2 Common response headers
| Header | Notes |
|---|---|
ETag | OCC token |
X-Correlation-Id | echoed |
X-RateLimit-Remaining | per-action bucket |
Content-Type | application/json (or application/problem+json on errors) |
X-Bytes-Scanned | for query-style endpoints, BigQuery bytes scanned |
X-Cache | HIT |
0.3 Standard error envelope (RFC 7807)
{
"type": "https://errors.melmastoon.ghasi.io/MELMASTOON.ANALYTICS.QUERY_BYTE_CAP_EXCEEDED",
"title": "Query exceeds byte cap",
"status": 413,
"code": "MELMASTOON.ANALYTICS.QUERY_BYTE_CAP_EXCEEDED",
"detail": "Query would scan 12.4 GB; cap for this widget is 1 GB.",
"instance": "/api/v1/analytics/widgets/wid_01H…/data",
"correlationId": "cor_01H…",
"violations": [{ "field": "filters.dateRange", "message": "Reduce range to ≤ 30 days." }]
}
0.4 Error codes (analytics-specific)
| Code | HTTP | Meaning |
|---|---|---|
MELMASTOON.ANALYTICS.WIDGET_NOT_FOUND | 404 | Widget id unknown for tenant |
MELMASTOON.ANALYTICS.METRIC_VERSION_CONFLICT | 409 | (key, version) already published |
MELMASTOON.ANALYTICS.PROJECTION_SCHEMA_MISMATCH | 422 | Widget/query references stale _schema_version |
MELMASTOON.ANALYTICS.QUERY_BYTE_CAP_EXCEEDED | 413 | Estimated scan exceeds cap |
MELMASTOON.ANALYTICS.QUERY_DEADLINE_EXCEEDED | 504 | BigQuery deadline reached |
MELMASTOON.ANALYTICS.CROSS_TENANT_QUERY | 403 | SQL references unauthorized tenant scope |
MELMASTOON.ANALYTICS.DASHBOARD_SCOPE_VIOLATION | 403 | Dashboard scope vs property scope mismatch |
MELMASTOON.ANALYTICS.PROPERTY_SCOPE_VIOLATION | 403 | JWT lacks property in filter |
MELMASTOON.ANALYTICS.DQ_CRITICAL | 422 | DQ rule failed critical, write blocked |
MELMASTOON.ANALYTICS.PROJECTION_NOT_READY | 503 | Projection still bootstrapping |
MELMASTOON.ANALYTICS.RATE_LIMITED | 429 | Per-tenant query rate cap |
MELMASTOON.ANALYTICS.BUDGET_EXHAUSTED | 402 | Per-tenant daily byte budget exhausted |
Common: MELMASTOON.IAM.AUTH_INVALID (401), …AUTHZ_DENIED (403), …CONCURRENT_MODIFICATION (412), …IDEMPOTENCY.KEY_COLLISION (422) | per platform |
1. Metric definitions
1.1 List metric definitions
GET /api/v1/analytics/metrics?archived=false&prefix=reservation.
200:
{
"items": [
{
"id": "met_01H…",
"key": "reservation.occupancy_pct",
"version": 1,
"display": { "default": "Occupancy %", "values": { "fa": "نرخ اشغال" } },
"unit": { "kind": "percent", "decimals": 2 },
"grain": "day",
"dimensions": [
{ "key": "property_id", "type": "string", "display": { "default": "Property" } },
{ "key": "room_type_id", "type": "string", "display": { "default": "Room type" } },
{ "key": "date", "type": "date", "display": { "default": "Date" } }
],
"params": [
{ "key": "from", "type": "date", "required": true },
{ "key": "to", "type": "date", "required": true },
{ "key": "property_id", "type": "string", "required": false }
],
"freshness": { "hot": true, "targetMinutes": 5, "alertAtMinutes": 15 },
"archived": false
}
],
"nextPageToken": null
}
1.2 Compute a metric
POST /api/v1/analytics/metrics/{key}:compute
{
"version": 1,
"params": { "from": "2026-04-15", "to": "2026-04-21", "property_id": "ppt_01H…" },
"filters": { "channel": ["direct","ota_booking"] }
}
200:
{
"key": "reservation.occupancy_pct",
"version": 1,
"value": 73.42,
"unit": { "kind": "percent", "decimals": 2 },
"windowFrom": "2026-04-15",
"windowTo": "2026-04-21",
"provenance": {
"computedAt": "2026-04-22T10:00:00.000Z",
"computedBy": "on_demand",
"bytesScanned": 18324567,
"slotMs": 1240,
"warehouseJobId": "bq-job-…"
}
}
Errors: QUERY_BYTE_CAP_EXCEEDED (413), QUERY_DEADLINE_EXCEEDED (504), PROJECTION_NOT_READY (503).
1.3 Publish a metric definition (admin)
POST /api/v1/analytics/metrics (analytics.admin)
{
"key": "channel.revenue_mix_pct",
"version": 1,
"display": { "default": "Channel revenue mix %", "values": {} },
"unit": { "kind": "percent", "decimals": 2 },
"grain": "day",
"dimensions": [ { "key": "channel", "type": "enum", "enumValues": ["direct","ota_booking","ota_expedia","meta","walk_in"], "display": { "default": "Channel" } } ],
"params": [ { "key": "from", "type": "date", "required": true }, { "key": "to", "type": "date", "required": true } ],
"sourceTables": ["fact_payment_v1", "fact_reservation_v1"],
"schemaVersion": 1,
"sqlTemplate": "SELECT channel, SAFE_DIVIDE(SUM(amount), SUM(SUM(amount)) OVER ()) * 100 AS value FROM `proj.analytics_curated.fact_payment_v1` WHERE tenant_id = @tenant_id AND business_date BETWEEN @from AND @to GROUP BY channel",
"byteCap": { "perQueryMaxBytes": 1073741824, "perTenantDailyMaxBytes": 53687091200 },
"freshness": { "hot": true, "targetMinutes": 5, "alertAtMinutes": 15 }
}
201 returns the persisted definition. 409 METRIC_VERSION_CONFLICT if the (key, version) already exists.
1.4 Archive
POST /api/v1/analytics/metrics/{key}/versions/{version}:archive
2. Projections
2.1 List
GET /api/v1/analytics/projections
200:
{
"items": [
{
"id": "prj_01H…",
"key": "fact_reservation",
"schemaVersion": 1,
"target": { "dataset": "analytics_curated", "table": "fact_reservation_v1" },
"freshness": { "targetMinutes": 5, "alertAtMinutes": 15, "lastSuccessLagMinutes": 3 },
"lastRun": { "id": "etr_01H…", "status": "succeeded", "succeededAt": "2026-04-22T09:55:00.000Z" }
}
]
}
2.2 Refresh manually (admin)
POST /api/v1/analytics/projections/{key}:refresh
{ "windowFrom": "2026-04-21T00:00:00Z", "windowTo": "2026-04-22T00:00:00Z", "trigger": "on_demand" }
202:
{ "etlRunId": "etr_01H…", "jobId": "etl_01H…", "status": "queued" }
2.3 Publish projection (admin)
POST /api/v1/analytics/projections
{
"key": "fact_reservation",
"schemaVersion": 1,
"target": { "dataset": "analytics_curated", "table": "fact_reservation_v1" },
"partitioning": { "field": "business_date", "type": "DAY" },
"clustering": ["tenant_id","property_id","room_type_id"],
"sourceQuerySql": "MERGE … USING (SELECT … FROM `proj.events_raw.melmastoon_reservation_reservation_confirmed_v1` …) S ON …",
"mergeKey": ["tenant_id","reservation_id","occurred_event_id"],
"refreshPolicy": "incremental",
"windowMinutes": 1440,
"freshness": { "targetMinutes": 5, "alertAtMinutes": 15 }
}
3. Queries
3.1 Run an ad-hoc curated query
POST /api/v1/analytics/queries:run (analytics.author)
{
"sourceTables": ["fact_reservation_v1"],
"sqlTemplate": "SELECT channel, COUNT(*) AS bookings FROM `proj.analytics_curated.fact_reservation_v1` WHERE tenant_id = @tenant_id AND business_date BETWEEN @from AND @to GROUP BY channel ORDER BY bookings DESC LIMIT 50",
"params": { "from": "2026-04-15", "to": "2026-04-21" },
"byteCap": { "perQueryMaxBytes": 1073741824 }
}
200:
{
"rows": [ { "channel": "direct", "bookings": 1284 }, { "channel": "ota_booking", "bookings": 941 } ],
"provenance": { "computedAt": "…", "computedBy": "on_demand", "bytesScanned": 982334, "slotMs": 410, "warehouseJobId": "bq-job-…" }
}
3.2 Save / update / delete a query
POST /api/v1/analytics/queries
PUT /api/v1/analytics/queries/{id}
DELETE /api/v1/analytics/queries/{id}
GET /api/v1/analytics/queries/{id}
3.3 Run a saved query
POST /api/v1/analytics/queries/{id}:run
{ "params": { "from": "2026-04-01", "to": "2026-04-30" } }
4. Dashboards & widgets
4.1 Create dashboard
POST /api/v1/analytics/dashboards (analytics.author)
{
"scope": "property",
"propertyId": "ppt_01H…",
"nameI18n": { "default": "Front desk daily" },
"description": null,
"sharedWith": []
}
201 returns the created dashboard with etag = 0.
4.2 Update dashboard
PUT /api/v1/analytics/dashboards/{id} (If-Match: <etag>)
4.3 Read dashboard
GET /api/v1/analytics/dashboards/{id} → returns dashboard + widgets array.
4.4 Add widget
POST /api/v1/analytics/dashboards/{id}/widgets (analytics.author)
{
"spec": {
"kind": "kpi_tile",
"metricRef": { "key": "reservation.occupancy_pct", "version": 1 },
"filters": { "property_id": "ppt_01H…" },
"granularity": "day",
"display": { "titleI18n": { "default": "Today's occupancy" } },
"thresholds": { "warn": 60, "crit": 40 }
},
"position": { "row": 0, "col": 0, "w": 4, "h": 2 }
}
4.5 Update widget
PUT /api/v1/analytics/widgets/{id} (If-Match)
4.6 Render widget data
GET /api/v1/analytics/widgets/{id}/data?from=2026-04-15&to=2026-04-21&refresh=false
200:
{
"widgetId": "wid_01H…",
"kind": "kpi_tile",
"rows": [ { "value": 73.42, "delta": -1.2 } ],
"provenance": { "computedAt": "2026-04-22T10:00:00.000Z", "computedBy": "on_demand", "bytesScanned": 18324567, "slotMs": 1240, "warehouseJobId": "bq-job-…" },
"cacheHit": false
}
refresh=true bypasses the cache. Headers include X-Cache and X-Bytes-Scanned.
4.7 Share dashboard (Looker Studio embed token)
POST /api/v1/analytics/dashboards/{id}:share (analytics.admin)
{ "shareWith": [{ "kind": "looker_studio_token", "expiresInMinutes": 60 }] }
200:
{
"tokens": [
{ "kind": "looker_studio_token", "value": "eyJhbGciOi…", "expiresAt": "2026-04-22T11:00:00Z" }
]
}
5. ETL & data quality
5.1 ETL job status
GET /api/v1/analytics/etl/jobs/{id} → returns ETLJob plus most recent ETLRun.
5.2 List recent runs
GET /api/v1/analytics/etl/runs?projection=fact_reservation&status=failed&limit=20
5.3 List DQ checks
GET /api/v1/analytics/data-quality?table=fact_reservation_v1
5.4 Latest DQ result for a check
GET /api/v1/analytics/data-quality/{checkId}/latest
200:
{
"checkId": "dqc_01H…",
"key": "fact_reservation.row_count_drift_24h",
"rule": { "kind": "row_count_drift", "table": "fact_reservation_v1", "windowMinutes": 1440, "baseline": { "method": "avg_28d", "valueOrTolerancePct": 30 } },
"severity": "critical",
"result": {
"id": "dqr_01H…",
"observedValue": 412,
"expectedValue": 1230,
"status": "critical",
"observedAt": "2026-04-22T10:00:00.000Z",
"windowFrom": "2026-04-21T10:00:00.000Z",
"windowTo": "2026-04-22T10:00:00.000Z",
"bigqueryJobId": "bq-job-…"
}
}
6. Internal endpoints
6.1 Sync pull (KPI snapshots)
GET /internal/sync/pull — see SYNC_CONTRACT §3. Returns dashboard + widget cached snapshots for the user's properties.
6.2 Scheduler / Workflows trigger
POST /internal/scheduler/etl
{ "trigger": "cron", "projectionKeys": ["fact_reservation","fact_payment"], "fireTime": "2026-04-22T10:00:00Z" }
OIDC-bound; returns { "queued": 2, "etlRunIds": [...] }.
6.3 Pub/Sub push handlers (signals + DQ)
POST /internal/pubsub/push/signals — handles melmastoon.ai.forecast.produced.v1, etc.; OIDC verified.
6.4 Looker Studio token verification
POST /internal/looker/verify — used by the Looker Studio Community Connector; verifies the embed token and returns the bound row-level filter.
7. Versioning
All endpoints are v1. Breaking changes go to v2 paths; backward-compatible additions are allowed in v1. OpenAPI 3.1 source of truth: bin/openapi.yaml. Spectral lint clean is a CI gate.
Cross-references: docs/05 API Design, docs/standards/ERROR_CODES, SECURITY_MODEL.