{"openapi":"3.1.0","info":{"title":"Dealer Lite · Services API","version":"1.0.0","description":"Machine-to-machine API over the Dealer Lite services layer (conversations, control/handoff, metrics). Each deployment belongs to one tenant; requests authenticate with a per-client API key issued for that tenant, so a client only ever sees its own dealer's data. Built for orchestration engines (Kestra — Martin AI) that surface their conversations in the Dealer Lite console."},"servers":[{"url":"/","description":"Same-origin: the deployment is the tenant."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer dlk_…` (an API key — machine clients) or `Authorization: Bearer <dash token>` (the token this API issued to an Agent Dash user at `POST /api/v1/auth/token`)."},"apiKeyHeader":{"type":"apiKey","in":"header","name":"x-api-key","description":"Same key, for clients that cannot send a bearer header."},"keycloakUser":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"The Agent Dash user's Keycloak access token. Accepted ONLY by `POST /api/v1/auth/token`, where it is verified against the realm's JWKS and the person's tenant is confirmed with the dash backend."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string","description":"Internal message, for logs — not for end users."},"code":{"type":"string","description":"Stable, translatable error code (when available)."}},"required":["error"]},"Agent":{"type":"object","properties":{"id":{"type":"string","description":"API clients use the \"api:<client>\" namespace."},"name":{"type":"string"},"title":{"type":"string"}},"required":["id","name"]},"Conversation":{"type":"object","description":"A switchboard chat projected for consumers: head-session id, customer, lifecycle status and control state.","properties":{"id":{"type":"string","description":"Head-session id — addresses the chat."},"chatKey":{"type":["string","null"],"description":"Stable key grouping the chat's episodes."},"sessionIds":{"type":"array","items":{"type":"string"}},"customer":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"channel":{"type":"string"},"interest":{"type":"string"}},"required":["id","name"]},"status":{"type":"object","properties":{"value":{"type":"string","enum":["active","completed","closed_inactive","closed"]},"changedBy":{"type":"object","properties":{"kind":{"type":"string","enum":["agent","operator","system"]},"id":{"type":"string"},"name":{"type":"string"}},"required":["kind"]},"changedAt":{"type":"string","format":"date-time"}},"required":["value","changedBy","changedAt"]},"controlMode":{"type":"string","enum":["ai","human"]},"controllers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"API clients use the \"api:<client>\" namespace."},"name":{"type":"string"},"title":{"type":"string"}},"required":["id","name"]}},"pendingHandoff":{"type":"object","properties":{"requestedBy":{"type":"object","properties":{"id":{"type":"string","description":"API clients use the \"api:<client>\" namespace."},"name":{"type":"string"},"title":{"type":"string"}},"required":["id","name"]},"requestedAt":{"type":"number","description":"Unix epoch ms."},"expiresAt":{"type":"number","description":"Unix epoch ms."}},"required":["requestedBy","requestedAt","expiresAt"]},"sentiment":{"type":"object","description":"Rolling customer sentiment (EWMA over the customer's messages), when it has been scored.","properties":{"score":{"type":"number","description":"Rolling score in [-1, 1]."},"label":{"type":"string","enum":["negative","neutral","positive"]},"lastAt":{"type":"string","format":"date-time","description":"Last scored message."},"escalatedAt":{"type":"string","format":"date-time","description":"Set when the assistant handed the chat off over negative sentiment."}},"required":["score","label"]},"summary":{"type":"object","description":"Operator-facing summary of the conversation, written automatically a few minutes after the last message. Absent until it has been generated.","properties":{"text":{"type":"string"},"callToAction":{"type":"string","description":"What the operator should do next. Only present when the customer shows dissatisfaction — its absence means nothing special is needed."},"language":{"type":"string","description":"Language it is written in (2-letter code): the conversation's own."},"generatedAt":{"type":"string","format":"date-time"},"lastMessageAt":{"type":"string","format":"date-time","description":"Last message it covers — how far the summary is up to date."},"messageCount":{"type":"number"},"translations":{"type":"object","description":"Cached translations keyed by locale, added on demand.","additionalProperties":{"type":"object","properties":{"text":{"type":"string"},"callToAction":{"type":"string"}},"required":["text"]}}},"required":["text","language"]},"lastMessagePreview":{"type":"string"},"lastActivityAt":{"type":"string","format":"date-time","description":"Polling watermark: pass it back as `since`."},"lastMessageAt":{"type":"string","format":"date-time"},"unreadCount":{"type":"number"}},"required":["id","customer","controlMode","controllers","lastMessagePreview","lastActivityAt","lastMessageAt","unreadCount"]},"Message":{"type":"object","properties":{"id":{"type":"string"},"conversationId":{"type":"string"},"author":{"type":"string","enum":["customer","ai","human","system"]},"body":{"type":"string"},"sentAt":{"type":"string","format":"date-time"},"eventKind":{"type":"string","enum":["took_control","returned_to_ai","handoff_completed","completed","closed_inactive","episode_start"],"description":"Only on `system` messages."},"eventActor":{"type":"object","properties":{"id":{"type":"string","description":"API clients use the \"api:<client>\" namespace."},"name":{"type":"string"},"title":{"type":"string"}},"required":["id","name"]},"operator":{"type":"object","properties":{"id":{"type":"string","description":"API clients use the \"api:<client>\" namespace."},"name":{"type":"string"},"title":{"type":"string"}},"required":["id","name"]},"attachments":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"kind":{"type":"string","enum":["image","document","audio","video"]},"name":{"type":"string"},"mimeType":{"type":"string"},"size":{"type":"number"},"url":{"type":"string"}},"required":["id","kind","mimeType","size","url"]}}},"required":["id","conversationId","author","body","sentAt"]},"ConversationPage":{"type":"object","description":"Cursor-paginated conversations, latest activity first.","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}},"nextCursor":{"type":["string","null"],"description":"Opaque; pass back unchanged to fetch the next page."},"hasMore":{"type":"boolean"}},"required":["items","nextCursor","hasMore"]},"MessagePage":{"type":"object","description":"Cursor-paginated messages, newest first, with control events merged in.","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"nextCursor":{"type":["string","null"],"description":"Opaque; pass back unchanged to fetch the next page."},"hasMore":{"type":"boolean"}},"required":["items","nextCursor","hasMore"]},"TakeControlResult":{"type":"object","properties":{"kind":{"type":"string","enum":["transferred","pending"],"description":"`transferred`: control is yours. `pending`: another controller holds it; a handoff request was created."},"conversation":{"$ref":"#/components/schemas/Conversation"}},"required":["kind","conversation"]},"ConversationMetrics":{"type":"object","properties":{"total":{"type":"number"},"inProgress":{"type":"number"},"completedByAgent":{"type":"number"},"completedByOperator":{"type":"number"},"closedInactive":{"type":"number"}},"required":["total","inProgress","completedByAgent","completedByOperator","closedInactive"]},"Panel":{"type":"object","description":"A dashboard panel resolved for a window, ready to draw.","properties":{"panel":{"type":"string"},"shape":{"type":"string","enum":["breakdown","timeseries","distribution"]},"unit":{"type":"string","enum":["count","ms","currency"]},"labelKey":{"type":"string"},"descriptionKey":{"type":"string"},"section":{"type":"string","enum":["conversations","campaigns","events"]},"hint":{"type":"string","enum":["donut","bars","line","histogram"]},"series":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"number"},"navigable":{"type":"boolean"}},"required":["key","value"]}},"navigable":{"type":"boolean"},"detailFrom":{"type":"string"},"total":{"type":"number"},"computedAt":{"type":["string","null"],"format":"date-time"},"missingDays":{"type":"number"}},"required":["panel","shape","unit","labelKey","descriptionKey","section","series","navigable","total","computedAt"]},"RollupResult":{"type":"object","properties":{"panel":{"type":"string"},"kind":{"type":"string","enum":["panel","refs","clean"]},"ok":{"type":"boolean"},"error":{"type":"string"}},"required":["panel","kind","ok"]},"AppointmentType":{"type":"object","description":"An entry of the tenant's appointment-type catalog.","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"active":{"type":"boolean"},"order":{"type":"integer"},"importMapping":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Field → column header of the last file uploaded for this type."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","description","active","order","createdAt","updatedAt"]},"AppointmentTypeInput":{"type":"object","properties":{"name":{"type":"string","maxLength":60},"description":{"type":"string","maxLength":280},"active":{"type":"boolean"},"order":{"type":"integer"}},"required":["name"]},"Appointment":{"type":"object","description":"An appointment of the appointment-type agenda: no slot, just a date and time.","properties":{"id":{"type":"string"},"appointmentTypeId":{"type":["string","null"]},"profileId":{"type":"string"},"customerName":{"type":["string","null"]},"phone":{"type":["string","null"]},"motorcycleId":{"type":"string"},"motorcycleModel":{"type":["string","null"]},"plate":{"type":["string","null"]},"comment":{"type":"string"},"scheduledFor":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["scheduled","confirmed","reschedule_requested","cancelled","completed","no_show"]},"source":{"type":["object","null"],"properties":{"type":{"type":"string"},"id":{"type":["string","null"]}}},"bookedAt":{"type":"string","format":"date-time"},"statusChangedAt":{"type":["string","null"],"format":"date-time"}},"required":["id","profileId","motorcycleId","scheduledFor","status","bookedAt"]},"AppointmentMetrics":{"type":"object","properties":{"total":{"type":"number"},"pending":{"type":"number"},"byStatus":{"type":"object","additionalProperties":{"type":"number"}},"byAppointmentType":{"type":"object","additionalProperties":{"type":"number"}}},"required":["total","pending","byStatus","byAppointmentType"]},"AppointmentImportResult":{"type":"object","properties":{"index":{"type":"integer"},"outcome":{"type":"string","enum":["ok","created","invalid","duplicate","failed"]},"reason":{"type":"string"},"fullname":{"type":"string"},"phone":{"type":"string"},"plate":{"type":"string"},"model":{"type":"string"},"scheduledFor":{"type":["string","null"],"format":"date-time"},"suppressed":{"type":"boolean"},"newCustomer":{"type":"boolean"},"appointmentId":{"type":"string"}},"required":["index","outcome"]},"AppointmentImportCounts":{"type":"object","properties":{"ok":{"type":"number"},"created":{"type":"number"},"invalid":{"type":"number"},"duplicate":{"type":"number"},"failed":{"type":"number"},"suppressed":{"type":"number"}}},"Motorcycle":{"type":"object","properties":{"id":{"type":"string"},"profileId":{"type":"string"},"model":{"type":"string"},"version":{"type":["string","null"]},"year":{"type":["number","null"]},"mileage":{"type":["number","null"]}},"required":["id","profileId","model","version","year","mileage"]},"ProfileServiceSummary":{"type":"object","properties":{"total":{"type":"number"},"cancelled":{"type":"number"},"byType":{"type":"object","properties":{"maintenance":{"type":"number"},"service":{"type":"number"}},"required":["maintenance","service"]},"upcoming":{"type":"number"}},"required":["total","cancelled","byType","upcoming"]},"ProfileDirectoryEntry":{"type":"object","description":"A customer profile with its garage (each bike carries its service history) and booking summary.","properties":{"id":{"type":"string"},"fullname":{"type":"string"},"phone":{"type":["string","null"],"description":"Digits only — the primary identifier."},"email":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"motorcycles":{"type":"array","items":{"$ref":"#/components/schemas/Motorcycle"}},"summary":{"$ref":"#/components/schemas/ProfileServiceSummary"},"chatChannel":{"type":["object","null"],"properties":{"channel":{"type":"string"},"externalUserId":{"type":"string"}},"required":["channel","externalUserId"]}},"required":["id","fullname","phone","email","createdAt","motorcycles","summary"]},"ProfilesPage":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ProfileDirectoryEntry"}},"total":{"type":"number"},"page":{"type":"number"},"limit":{"type":"number"}},"required":["items","total","page","limit"]},"ProfileInput":{"type":"object","properties":{"fullname":{"type":"string"},"phone":{"type":"string","description":"Normalised to digits by the resolve workflow."},"email":{"type":"string"}},"required":["fullname","phone"]},"MotorcycleInput":{"type":"object","properties":{"model":{"type":"string"},"version":{"type":"string"},"year":{"type":"number"},"mileage":{"type":"number"}},"required":["model"]},"ProfileWithGarage":{"type":"object","description":"A customer profile with its garage (no booking summary).","properties":{"id":{"type":"string"},"fullname":{"type":"string"},"phone":{"type":["string","null"]},"email":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"motorcycles":{"type":"array","items":{"$ref":"#/components/schemas/Motorcycle"}}},"required":["id","fullname","phone","email","createdAt","motorcycles"]}}},"paths":{"/api/v1/auth/token":{"post":{"summary":"Exchange an Agent Dash user's Keycloak token for a dash token","description":"Verifies the Keycloak access token (signature, `iss`, `exp`, `aud`), confirms with the dash backend that the person belongs to a tenant this deployment serves, that the tenant has the Service module enabled, and that the person holds the module tag, and issues a short-lived token signed by this API. Data routes accept that token as a bearer. `X-Impersonate-Tenant` (dash multi-client view) yields a read-only token.","security":[{"keycloakUser":[]}],"parameters":[{"name":"X-Impersonate-Tenant","in":"header","required":false,"schema":{"type":"string"},"description":"Dash tenant a crossaccount user is looking at; must be one this deployment serves."}],"responses":{"200":{"description":"The dash token and who it represents.","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"},"agent":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"user":{"type":"object","properties":{"sub":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"dashTenant":{"type":"string"},"crossaccount":{"type":"boolean"}},"required":["sub","email","name","dashTenant","crossaccount"]},"tenant":{"type":"string"},"readOnly":{"type":"boolean"}},"required":["token","expiresAt","agent","user","tenant","readOnly"]}}}},"401":{"description":"`invalid_user_token` (bad or rejected Keycloak token) or `dash_users_disabled` (not configured here).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"`tenant_mismatch` | `service_disabled` | `tag_required` | `tenant_required` | `user_inactive`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"`dash_unavailable` — the dash backend did not answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/health":{"get":{"summary":"Liveness probe","description":"Unauthenticated; says nothing about the tenant.","responses":{"200":{"description":"The process answers.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"service":{"type":"string"}},"required":["ok","service"]}}}}}}},"/api/v1/openapi":{"get":{"summary":"This document","description":"The OpenAPI contract, served by the app that fulfils it. Public: shapes, not data.","responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/json":{}}}}}},"/api/v1/conversations":{"get":{"summary":"List conversations","description":"Page of conversations, latest activity first.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":50,"default":10}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"`nextCursor` from the previous page; opaque."}],"responses":{"200":{"description":"One page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationPage"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/updates":{"get":{"summary":"Conversation updates since a watermark","description":"Conversations with activity (messages or control changes) strictly newer than `since`. Use the highest `lastActivityAt` returned as the next watermark.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"since","in":"query","required":true,"schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Updated conversations.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}}},"required":["items"]}}}},"400":{"description":"Missing or invalid `since`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}":{"get":{"summary":"One conversation","description":"Lifecycle and control state. If `controlMode` is `human`, a person is attending — the AI should stay quiet.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The conversation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Conversation"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown conversation id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}/messages":{"get":{"summary":"Message history","description":"Newest first, with control events merged in as `system` messages.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"before","in":"query","schema":{"type":"string"},"description":"`nextCursor` from the previous page; opaque."}],"responses":{"200":{"description":"One page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessagePage"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Send a reply","description":"Appends an outbound reply attributed to the API client's Agent identity and delivers it to the customer's channel. The client must hold control of the conversation (see …/control `take`), same rule as a human operator.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string"},"attachments":{"type":"array","items":{"type":"string"},"description":"Attachment ids previously uploaded via …/{id}/attachments."}},"required":["body"]}}}},"responses":{"200":{"description":"The appended message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"400":{"description":"Missing body, empty message, over the length cap, or sender does not hold control.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown conversation id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}/messages/updates":{"get":{"summary":"Message updates since a watermark","description":"Messages and control events strictly newer than `since`, oldest → newest.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"since","in":"query","required":true,"schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"New messages.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}},"required":["items"]}}}},"400":{"description":"Missing or invalid `since`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}/control":{"post":{"summary":"Mutate control state","description":"Same state machine as the console: `take` (become controller, or open a pending handoff when someone else holds it), `release-to-ai` (optionally answering the pending customer message), `complete`, `reject-handoff`, `resolve-handoff`. The acting Agent is derived from the API key, never from the body.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"action":{"type":"string","enum":["take","release-to-ai","complete","reject-handoff","resolve-handoff"]},"replyToPending":{"type":"boolean","description":"Only for `release-to-ai`."}},"required":["action"]}}}},"responses":{"200":{"description":"`take` returns a TakeControlResult; every other action returns the updated Conversation.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/TakeControlResult"},{"$ref":"#/components/schemas/Conversation"}]}}}},"400":{"description":"Missing/unknown action or an invalid transition.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown conversation id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/metrics/conversations":{"get":{"summary":"Aggregate conversation counts","description":"Live counts over the tenant's sessions; independent of the rollup.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"The counters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationMetrics"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/metrics/panels":{"get":{"summary":"Dashboard panels","description":"Panels resolved for a window. Keys as in the console dashboard (e.g. `conversations.resolution`, `conversations.volume`, `response_time.ai`, `response_time.human`, `handoffs.to_human`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"panels","in":"query","required":true,"schema":{"type":"string"},"description":"Comma-separated panel keys."},{"name":"window","in":"query","schema":{"type":"string","enum":["1","7","15","30","all"],"default":"7"}},{"name":"scope","in":"query","schema":{"type":"string","default":"all"},"description":"Narrows scoped sections (e.g. one campaign); `all` for the total."}],"responses":{"200":{"description":"The requested panels.","content":{"application/json":{"schema":{"type":"object","properties":{"panels":{"type":"array","items":{"$ref":"#/components/schemas/Panel"}}},"required":["panels"]}}}},"400":{"description":"Missing `panels` or invalid `window`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/metrics/rollup":{"post":{"summary":"Recompute dashboard panels","description":"Runs the metrics rollup for the last `sinceDays` days (default 3). The bell the orchestration engine rings after processing, so the dashboard reflects its traffic.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"sinceDays":{"type":"integer","minimum":1}}}}}},"responses":{"200":{"description":"One result per pipeline.","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/RollupResult"}}},"required":["results"]}}}},"400":{"description":"Invalid `sinceDays`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types":{"get":{"summary":"List appointment types","description":"The tenant's catalog, in selector order.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"includeInactive","in":"query","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"The catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AppointmentType"}}},"required":["items"]}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create an appointment type","description":"400 `name_required` | `name_too_long` | `order_invalid`; 409 `name_taken`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentTypeInput"}}}},"responses":{"201":{"description":"The new type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}":{"get":{"summary":"Get an appointment type","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`type_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"summary":"Update an appointment type","description":"There is no DELETE: a type with history is deactivated (`active: false`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentTypeInput"}}}},"responses":{"200":{"description":"The updated type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/confirmation":{"put":{"summary":"Configure the WhatsApp confirmation of a type","description":"Message (named variables fullname, date, time, plate, model, type, deadline), the CONFIRM / MODIFY / CANCEL button texts, when it is sent (`hours_before` or `days_before` at a fixed time), the reply deadline (`none`, `hours_after_send`, `days_before`) and the answers. Changing text or buttons sets the Meta template back to `none`. 400 `body_invalid` | `variable_unknown` | `button_invalid` | `reply_invalid` | `send_invalid` | `deadline_invalid`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"The type with its confirmation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/confirmation/template":{"post":{"summary":"Submit the confirmation message to Meta for approval","description":"Creates a new UTILITY template with quick-reply buttons on the connected WABA and deletes the previous one. 422 `whatsapp_not_connected`; 502 `meta_error`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The type with the template submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/confirmation/template/status":{"post":{"summary":"Refresh the confirmation template status from Meta","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The type with the template status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments/{id}/confirmation":{"post":{"summary":"Send the WhatsApp confirmation of an appointment now","description":"Without waiting for the configured time. Stored in the customer's conversation, with the reference on the appointment. 409 `confirmation_not_open` | `confirmation_too_late`; 422 `template_not_approved` | `whatsapp_not_connected` | `confirmation_no_phone` | `confirmation_suppressed`; 502 `meta_error`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The appointment with its confirmation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Appointment"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments":{"get":{"summary":"Appointment agenda","description":"`scope=pending`: open and upcoming, soonest first. `scope=history`: past or closed, latest first. Without `appointmentTypeId`, the whole agenda.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"appointmentTypeId","in":"query","schema":{"type":"string"}},{"name":"scope","in":"query","schema":{"type":"string","enum":["pending","history"]}},{"name":"status","in":"query","description":"Comma-separated.","schema":{"type":"string"}},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"q","in":"query","description":"Name, phone, plate, model or comment.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"One page plus the total.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Appointment"}},"total":{"type":"number"}},"required":["items","total"]}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create an appointment","description":"Manual entry, no slot. The bike travels as `motorcycleId` or as `motorcycle` ({model, plate?}). 400 `type_inactive` | `date_invalid` | `date_past` | `model_required` | `motorcycle_not_in_garage` | `appointment_duplicate`; 404 `type_not_found` | `profile_not_found`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"appointmentTypeId":{"type":"string"},"profileId":{"type":"string"},"motorcycleId":{"type":"string"},"motorcycle":{"type":"object","properties":{"model":{"type":"string"},"plate":{"type":"string"},"year":{"type":"number"}}},"scheduledFor":{"type":"string","format":"date-time"},"comment":{"type":"string"}},"required":["appointmentTypeId","profileId","scheduledFor"]}}}},"responses":{"201":{"description":"The new appointment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Appointment"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments/{id}":{"get":{"summary":"Get an appointment","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The appointment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Appointment"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"summary":"Change an appointment's status, or reschedule it","description":"`{ status }` changes the status; `{ scheduledFor }` reschedules an open appointment and sets it back to `scheduled` (the new date is not confirmed yet). Both are recorded in the appointment's history with the caller's identity. 400 `appointment_closed` | `date_past` | `appointment_duplicate`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["scheduled","confirmed","reschedule_requested","cancelled","completed","no_show"]},"scheduledFor":{"type":"string","format":"date-time"}}}}}},"responses":{"200":{"description":"The updated appointment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Appointment"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments/metrics":{"get":{"summary":"Appointment counters","description":"`total`, `byStatus` and `byAppointmentType` within the window (appointment date) and the scope — the same appointments the list of that scope shows; `pending` (open and upcoming) regardless of both.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"appointmentTypeId","in":"query","schema":{"type":"string"}},{"name":"scope","in":"query","schema":{"type":"string","enum":["pending","history"]}},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"The counters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentMetrics"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments/import":{"post":{"summary":"Import appointments from a file","description":"The browser parses the file and sends rows as text. `dryRun: true` validates up to 2000 rows without writing (the preview); `dryRun: false` loads up to 50. The first loading call creates the batch and returns `batchId`; later ones send it back.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"appointmentTypeId":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","description":"Appointment type by name, when the request has no appointmentTypeId."},"fullname":{"type":"string"},"phone":{"type":"string"},"plate":{"type":"string"},"model":{"type":"string"},"date":{"type":"string","description":"YYYY-MM-DD or DD/MM/YYYY."},"time":{"type":"string","description":"HH:MM."},"comment":{"type":"string"}}}},"dryRun":{"type":"boolean"},"batchId":{"type":"string"},"fileName":{"type":"string"},"offset":{"type":"integer"},"total":{"type":"integer"},"mapping":{"type":"object","additionalProperties":{"type":"string"}}},"required":["rows","dryRun"]}}}},"responses":{"200":{"description":"One result per row, plus the counts.","content":{"application/json":{"schema":{"type":"object","properties":{"batchId":{"type":["string","null"]},"dryRun":{"type":"boolean"},"results":{"type":"array","items":{"$ref":"#/components/schemas/AppointmentImportResult"}},"counts":{"$ref":"#/components/schemas/AppointmentImportCounts"}},"required":["batchId","dryRun","results","counts"]}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments/imports":{"get":{"summary":"Import history of a type","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"appointmentTypeId","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The latest imports, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"appointmentTypeId":{"type":["string","null"]},"appointmentTypeIds":{"type":"array","items":{"type":"string"}},"fileName":{"type":"string"},"createdBy":{"type":["object","null"]},"total":{"type":"number"},"counts":{"$ref":"#/components/schemas/AppointmentImportCounts"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}}}},"required":["items"]}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/profiles":{"get":{"summary":"Customer directory","description":"Registered profiles with their garage (each bike carries its service history) and booking summary. `sort=pending_first` floats customers with upcoming bookings.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"page","in":"query","schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"default":10}},{"name":"search","in":"query","schema":{"type":"string"}},{"name":"sort","in":"query","schema":{"type":"string","enum":["name","pending_first","pending_last"],"default":"name"}}],"responses":{"200":{"description":"One page of the directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfilesPage"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Register a customer","description":"Creates a standalone profile, deduplicated by phone through switchboard's resolve workflow. A phone held by a logically-deleted profile answers 409 with the existing record so the operator can decide.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileInput"}}}},"responses":{"201":{"description":"The new profile with its (empty) garage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithGarage"}}}},"400":{"description":"Missing or invalid fields.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The phone belongs to a logically-deleted profile (`email_conflict`).","content":{"application/json":{}}}}}},"/api/v1/profiles/{id}":{"put":{"summary":"Edit a customer","description":"Name/phone/email; the phone is normalised and mirrored to the WhatsApp identity.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileInput"}}}},"responses":{"200":{"description":"The updated profile with its garage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithGarage"}}}},"400":{"description":"Missing or invalid fields.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown profile id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Logically delete a customer","description":"Stamps `deleted_at`; the profile drops out of the directory. 204 on success.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted."},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/profiles/{id}/motorcycles":{"post":{"summary":"Register a bike in the garage","description":"Through switchboard's motorcycle-upsert workflow — the AI agent's own path.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotorcycleInput"}}}},"responses":{"201":{"description":"The registered bike.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Motorcycle"}}}},"400":{"description":"Missing model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown profile id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/profiles/{id}/motorcycles/{bikeId}":{"put":{"summary":"Edit a garage bike","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"bikeId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MotorcycleInput"}}}},"responses":{"200":{"description":"The updated bike.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Motorcycle"}}}},"400":{"description":"Missing model.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown profile or bike id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Remove a bike from the garage","description":"409 `motorcycle_has_appointments` (with `pendingAppointments`) when the bike still has upcoming appointments; `?force=true` cancels them first — freeing each slot — and then deletes. Without the guard those appointments would point at a vehicle that no longer exists.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"bikeId","in":"path","required":true,"schema":{"type":"string"}},{"name":"force","in":"query","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Removed; `cancelled` is how many appointments were cancelled with it.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown profile or bike id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The bike has upcoming appointments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/config/business":{"get":{"summary":"Business info shown to customers","description":"Name, phone and address of the workshop as written into customer-facing messages: the `{{dealer.name}}`, `{{dealer.phone}}` and `{{dealer.address}}` template variables. `phone` is the tenant's WhatsApp line (`business_number`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"`{ name, phone, address }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"summary":"Update business info","description":"Partial: only the fields present are updated. Each is trimmed and capped at 200 chars.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"phone":{"type":"string"},"address":{"type":"string"}}}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/config/reopen-template":{"get":{"summary":"The conversation re-open template","description":"The approved WhatsApp template used to retake a conversation after the 24h window, with its Meta status: none | draft | pending | approved | rejected. Only `approved` with a content SID is `usable`. `body` uses named variables ({{fullname}}, {{motorcycle.model}}, {{dealer.name}}, {{dealer.phone}}, {{dealer.address}}); `metaBody` is the positional version ({{1}}…) to load in Meta and `variables` lists the names in that order.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"The template and its state.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"summary":"Save the re-open template","description":"`body` must use at least one variable from the catalog (422 `variable_required`) and none outside it (422 `variable_unknown`). `status: pending` SUBMITS the template to Meta (via Twilio, same n8n webhook campaigns use) and stores the content SID and the status Meta answered; 422 `template_submit_failed` leaves it as `draft`. `status` only accepts `draft` or `pending` — approving is Meta's call, and letting it be set by hand would make the send go out and be rejected. Editing the text drops the content SID and returns it to `draft`: Meta approved THAT text.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string"},"status":{"type":"string","enum":["draft","pending"]}},"required":["body"]}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/config/reopen-template/status":{"post":{"summary":"Refresh the re-open template's Meta status","description":"Asks Twilio (via n8n) whether Meta approved or rejected the re-open template and persists the answer. Without a content SID there is nothing to ask: returns the template unchanged.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"The template and its (refreshed) state.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}/reopen":{"post":{"summary":"Re-open a conversation with the approved template","description":"Sends the tenant's approved WhatsApp template — the only thing Meta lets through once the 24h customer-service window has closed. Variables resolve from the customer's profile (name, latest motorcycle) and the business info (`/config/business`); `name` is the fallback for {{fullname}} on conversations without a profile. Marks the conversation as awaiting the customer's reply (`reopen.awaitingReply` on GET). Free-form text outside the window is rejected by POST /messages with 422 `service_window_closed`; this is the way back in. 422 `reopen_template_missing` when the tenant has no template configured (`config.reopen_content_sid`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}}}}}},"responses":{"200":{"description":"Template delivered; `body` is the text the customer receives, `sentAt` when.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}/seen":{"post":{"summary":"Read-receipt","description":"Advances the client's read watermark while it has the conversation open, so the unseen-message signal only fires when no controller is looking. No-op unless the client holds control.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Marked."},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conversations/{id}/profile":{"get":{"summary":"Detect the conversation's customer","description":"The linked profile with its garage (`{ profile, sender }`), or `profile: null` for an unregistered sender — the step before booking from the chat.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Profile (or null) plus the sender identity.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Register the conversation's sender","description":"Runs the profile-resolve workflow and links the profile to the conversation. 409 when the identity is held by a logically-deleted profile.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"fullname":{"type":"string"},"email":{"type":"string"}},"required":["fullname"]}}}},"responses":{"201":{"description":"The registered profile with its garage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithGarage"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Identity held by a deleted profile.","content":{"application/json":{}}}}}},"/api/v1/conversations/{id}/attachments":{"post":{"summary":"Upload an attachment for a reply","description":"Raw bytes in the body, `name`/`type` in the query. Returns the stored attachment; its id then travels in the reply's `attachments`. 20 MiB cap.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"name","in":"query","schema":{"type":"string"}},{"name":"type","in":"query","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"201":{"description":"The stored attachment, with its serving URL.","content":{"application/json":{}}},"400":{"description":"Missing bytes or unsupported type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/attachments/{id}":{"get":{"summary":"Download an attachment","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The file bytes.","content":{"application/octet-stream":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown attachment id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/metrics/drill":{"get":{"summary":"Conversations behind a panel slice","description":"The drill list the console opens on click: pass `panel`, optionally `key` (one slice), `window`, `scope`, `page`, `q` and a `from`/`to` day range.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"panel","in":"query","required":true,"schema":{"type":"string"}},{"name":"key","in":"query","schema":{"type":"string"}},{"name":"window","in":"query","schema":{"type":"string","enum":["1","7","15","30","all"],"default":"7"}},{"name":"scope","in":"query","schema":{"type":"string","default":"all"}},{"name":"page","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"q","in":"query","schema":{"type":"string"}},{"name":"from","in":"query","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"The drill rows.","content":{"application/json":{}}},"400":{"description":"Missing panel or invalid window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/profiles/{id}/purge":{"post":{"summary":"Hard-delete a customer","description":"Removes the profile with its garage and service history — used to free an identity held by a logically-deleted profile. Irreversible.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Purged."},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/profiles/{id}/conversation":{"post":{"summary":"Open a chat with the customer","description":"Returns the active session for the profile's WhatsApp identity, or a freshly-created empty one. Requires the profile to have a channel set. `{ conversationId, created }`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The conversation to open.","content":{"application/json":{}}},"400":{"description":"The profile has no channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/channels/whatsapp/connection":{"get":{"summary":"WhatsApp Cloud connection of the tenant","description":"`status` (`connected` | `none`), `setup` (what the browser needs to open the Meta Embedded Signup of the Meta app configured on this deployment: appId, configId, graphVersion — nothing secret; null when not configured) and the connection: mode, number, when and who, the Meta app it was made with (`appMismatch` when it is not the configured one), until when the history can still be requested, and the history/contacts import progress (`sync`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"The connection view.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Connect (or replace) the WhatsApp number","description":"Finishes the Embedded Signup run in the browser: exchanges the code, registers (cloud) or syncs (coexistence) the number, subscribes the app and stores the connection with its token sealed. Replaces an existing number. Recorded in the connection history. 400 `code_required` | `waba_required` | `phone_required`; 422 `meta_not_configured`; 502 `meta_error` with Meta's message.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"string"},"wabaId":{"type":"string"},"phoneNumberId":{"type":"string","description":"Required for `cloud`."},"mode":{"type":"string","enum":["cloud","coexistence"]},"importHistory":{"type":"boolean","default":true}},"required":["code","wabaId"]}}}},"responses":{"201":{"description":"The connection view after connecting.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Disconnect the WhatsApp number","description":"Unsubscribes the app from the WABA and drops the stored token; the number is not deregistered. Recorded in the history. `{ warning }` with what Meta refused, if anything. 404 `whatsapp_not_connected`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"Disconnected.","content":{"application/json":{"schema":{"type":"object","properties":{"warning":{"type":["string","null"]}}}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/channels/whatsapp/connection/history":{"post":{"summary":"Request the chat history and contacts import","description":"Coexistence only, once per connection and within 24 h of connecting (Meta's rule). Progress is read in `connection.sync`. 404 `whatsapp_not_connected`; 409 `history_not_coexistence` | `history_already_requested` | `history_window_over`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"The connection view with the import requested.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/channels/whatsapp/connection/events":{"get":{"summary":"Connection history","description":"Newest first: connected, replaced, disconnected, history requested — who, which number and which Meta app.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"The events.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object"}}},"required":["items"]}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/channels/whatsapp/connection/number":{"get":{"summary":"Live status of the connected number in Meta","description":"Verified name and its status, quality rating, messaging limit, account mode, WABA name and business verification, read live from Graph. Missing fields are null; `error` says why when Meta does not answer. 404 `whatsapp_not_connected`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"What Meta reports.","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/channels/whatsapp/send":{"post":{"summary":"Send a WhatsApp message through the tenant's Cloud API number","description":"Body: `{ to, text }` — `to` in international digits (a webhook `wa_id`). Sends via Meta Graph with the tenant's sealed business token; valid inside the 24-hour customer-service window, i.e. replies. 409 when the tenant has no Embedded Signup connection.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"`{ id, from, to }` — `id` is the Graph message id.","content":{"application/json":{}}},"400":{"description":"Invalid input.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"No WhatsApp Cloud connection for this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Graph rejected the message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}