{"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":"One deployment for every tenant: each request operates on the caller's 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."},"opsSecret":{"type":"apiKey","in":"header","name":"x-ops-secret","description":"The flow engine's (n8n) shared secret, `N8N_OPS_SECRET`. Accepted ONLY by the `/api/v1/ops/*` routes."}},"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 Dealer Lite 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"},"escalatedBySentiment":{"type":"number"},"awaitingAdvisor":{"type":"number","description":"Abiertas, derivadas a un asesor y sin que nadie haya tomado el control."},"awaitingAdvisorByReason":{"type":"object","additionalProperties":{"type":"number"},"description":"Las mismas, por motivo del pasaje (appointment_request, reschedule_requested, maintenance_booking, requested, …; unknown sin motivo)."},"pendingHandoffs":{"type":"number","description":"Abiertas con un traspaso entre operadores que nadie aceptó y no venció."}},"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"},"kind":{"type":"string","enum":["appointments","maintenance"],"description":"`appointments` (the agenda, its confirmation and survey) or `maintenance` (the maintenance reminder, MRS: a base of vehicles offered an appointment, no appointments of its own). Set on create."},"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."},"survey":{"type":["object","null"],"description":"The satisfaction survey of the type, summarized. `ready`: a published version and the initial message approved by Meta — an attended appointment can get it by hand.","properties":{"enabled":{"type":"boolean"},"publishedVersion":{"type":["integer","null"]},"templateStatus":{"type":"string","enum":["none","pending","approved","rejected"]},"ready":{"type":"boolean"}},"required":["enabled","publishedVersion","templateStatus","ready"]},"maintenance":{"type":["object","null"],"description":"The maintenance reminder config (`enabled`, `body`, `buttons`, `send`, `replies`, `template`); `null` if never set up."},"reminder":{"type":["object","null"],"description":"The appointment reminder of the type (`enabled`, `body`, `template`); `null` if never set up. When it goes out is set per appointment."},"maintenanceImportMapping":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Field → column header of the last maintenance base uploaded for this type."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Logical delete; only listed with `includeDeleted=1`."},"deletedBy":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"required":["id","name","description","active","order","createdAt","updatedAt"]},"AppointmentTypeInput":{"type":"object","properties":{"kind":{"type":"string","enum":["appointments","maintenance"],"description":"Only on create; defaults to `appointments`."},"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"},"reminder":{"type":["object","null"],"description":"The appointment reminder: `rule` (set on upload), `dueAt`, `sentAt`, `manual`, `sessionId`, `messageId`, `deliveryStatus`, `skipped`, `error`. `null` for appointments loaded before reminders existed."}},"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"]}},{"name":"includeDeleted","in":"query","description":"Also the deleted types (to name old appointments).","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":"A type no longer used is deactivated (`active: false`) or deleted (DELETE).","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"}}}}}},"delete":{"summary":"Delete an appointment type (logical)","description":"The type stops being listed and offered, is deactivated and its name becomes free; `deletedAt`/`deletedBy` are kept. Its appointments are untouched and keep naming it (list with `includeDeleted=1`). Returns `{ type, usage }`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"`{ type, usage }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`type_not_found` (or already deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/usage":{"get":{"summary":"How many appointments a type has","description":"`{ pending, total }`: pending = unconfirmed, confirmed or with a reschedule request. What to warn about before deleting it.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"AppointmentTypeUsage.","content":{"application/json":{}}},"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"}}}}}}},"/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/appointment-types/{id}/reminder":{"put":{"summary":"Configure the appointment reminder of a type","description":"`{ enabled, body }`: a message without buttons (named variables fullname, date, time, plate, model, type) and whether it goes out on its own. When it goes out is set per appointment on upload (`reminderRule`: `on_upload`, `hours_before`, `days_before`, `days_before_at`). Changing the text sets the Meta template back to `none`. 400 `body_invalid` | `variable_unknown` | `type_is_maintenance`.","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 reminder.","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}/reminder/template":{"post":{"summary":"Submit the reminder message to Meta for approval","description":"Creates a new UTILITY template without buttons on the connected WABA and deletes the previous one. 400 `reminder_missing`; 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}/reminder/template/status":{"post":{"summary":"Refresh the reminder 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}/reminder":{"post":{"summary":"Send the reminder of an open appointment now","description":"Unconfirmed or confirmed; without waiting for its due time, and it can be sent again. Marked as manual: it no longer goes out on its own. Stored in the customer's conversation, with the reference on the appointment. 409 `reminder_not_open` | `reminder_too_late`; 422 `template_not_approved` | `whatsapp_not_connected` | `reminder_no_phone` | `reminder_suppressed`; 502 `meta_error`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The appointment with its reminder.","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. `kind: maintenance` loads the base of vehicles due for service instead: no date nor time, `comment` is the reason, and each row creates a maintenance reminder (`reminderId`); a vehicle with an open reminder is a duplicate.","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 Dealer Lite'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 Dealer Lite'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/advisor-hours":{"get":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"The advisors' (people) working hours","description":"`{ timezone, days: [{ day: 0..6 (0 = Sunday), open, from: 'HH:MM', to: 'HH:MM' }], isDefault? }`. Not configured: Monday to Friday, 9 to 18. Used in the handoff message and to measure waits in business minutes.","responses":{"200":{"description":"The hours.","content":{"application/json":{}}}}},"put":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Save the advisors' working hours","description":"Body `{ days: [7 × { open, from, to }], timezone? }`.","requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"The saved hours.","content":{"application/json":{}}},"400":{"description":"`hours_invalid`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/config/assistant":{"get":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Whether the assistant answers customers on its own","description":"`{ enabled, changedAt, changedBy }`. Not configured: enabled. Off, every conversation is treated as attended by a person (no automatic reply); messages still come in, and confirmations, surveys and reminders still go out.","responses":{"200":{"description":"The switch.","content":{"application/json":{}}}}},"put":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Turn the assistant on or off","description":"Body `{ enabled: boolean }`.","requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"The saved switch.","content":{"application/json":{}}},"400":{"description":"`enabled_invalid`.","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; `message` is the operator's own line, which fills the template's {{mensaje}} slot (required when the template has it: `reopen_message_required`). A tenant on Meta sends through its own number with the base template configured on its own. 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"}}}}}}},"/api/v1/ops/confirmations/sweep":{"post":{"summary":"Sweep the appointment confirmations of every tenant (flow engine)","description":"Called by the flow engine every few minutes, once for all tenants. For each tenant with an appointment type whose WhatsApp confirmation is on and approved by Meta, sends the confirmations that are due and cancels the appointments whose deadline passed without a reply. Then, for each tenant with a type whose reminder is on and approved, sends the reminders of open appointments whose due time (set on upload) arrived and that were not sent yet. A failing tenant does not stop the others. Body: none.","security":[{"opsSecret":[]}],"responses":{"200":{"description":"`{ tenants: { <tenant>: { sent, skipped, failed, expired } | { error } }, reminders: { tenants: { <tenant>: { sent, skipped, failed } | { error } } } }`.","content":{"application/json":{}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/holidays/sync":{"post":{"summary":"Import the public holidays of every tenant (flow engine)","description":"Called by the flow engine once a day, once for all tenants. For each tenant registered in Dealer Lite's core `config`, fetches this year's and next year's public holidays for its region (`config.localization`: country and optional ISO 3166-2 subdivision; the country falls back to the one of the timezone) and stores them in `holidays`. Argentina uses ArgentinaDatos (it brings the tourist bridge days) and falls back to Nager.Date; every other country uses Nager.Date. Holidays removed or renamed by a person are left alone. A failing tenant does not stop the others. Body: none.","security":[{"opsSecret":[]}],"responses":{"200":{"description":"`{ tenants: { <tenant>: { country, provider, years, created, removed, failedYears } | { error } } }`.","content":{"application/json":{}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/confirmations/reply":{"post":{"summary":"A customer tapped a button of an appointment confirmation (flow engine)","description":"Body: `{ tenant, payload, text?, wamid?, contextId?, from?, at? }` — what Meta's webhook brought (`payload` is `cf:<appointment>:<confirm|modify|cancel>`) and the tenant of the number that received it. Updates the appointment, stores the tap and the reply in the conversation and answers the customer. Idempotent by wamid (`duplicate: true`).","security":[{"opsSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"`{ handled, action, appointmentId, sessionId, duplicate? }`.","content":{"application/json":{}}},"400":{"description":"No `payload`, or no valid `tenant` (`tenant_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/maintenance":{"put":{"summary":"Save the maintenance reminder of an appointment type","description":"Body: `{ enabled, body, buttons: {book, decline}, send, replies: {book, decline} }`. Variables: `{{fullname}}`, `{{plate}}`, `{{model}}`, `{{type}}`, `{{reason}}`. `send`: `{mode: on_upload}` or `{mode: days_after_upload, days, time: HH:MM}`. Changing the text or buttons sets the Meta template back to `none`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"The type, with `maintenance`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppointmentType"}}}},"400":{"description":"`body_invalid`, `variable_unknown`, `button_invalid`, `reply_invalid`, `send_invalid`.","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/{id}/maintenance/template":{"post":{"summary":"Submit the maintenance reminder message to Meta","description":"Creates a new template (two quick replies) in the connected WhatsApp Business account and deletes the previous one.","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"}}}},"400":{"description":"`maintenance_missing` / `template_missing`.","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"}}}},"422":{"description":"`whatsapp_not_connected`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"`meta_error`: Meta rejected the call (with what it said).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/maintenance/template/status":{"post":{"summary":"Refresh the maintenance reminder template status from Meta","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"}}}},"502":{"description":"`meta_error`: Meta rejected the call (with what it said).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/maintenance-reminders":{"get":{"summary":"List maintenance reminders","description":"Newest first, with `counts` per status (ignoring the status filter). A reminder is created per row of a `kind: maintenance` upload (`POST /appointments/import`). `toCall=1`: the ones that asked for an appointment and nobody marked as contacted.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"appointmentTypeId","in":"query","schema":{"type":"string"}},{"name":"status","in":"query","description":"Comma separated: pending, sent, booked, declined, skipped, failed, cancelled.","schema":{"type":"string"}},{"name":"toCall","in":"query","schema":{"type":"string","enum":["1"]}},{"name":"q","in":"query","description":"Customer, phone, plate, model or reason.","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"`{ items: MaintenanceReminder[], total, counts }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/maintenance-reminders/{id}/send":{"post":{"summary":"Send a maintenance reminder now","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"MaintenanceReminder.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`reminder_not_pending`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`template_not_approved`, `whatsapp_not_connected`, `reminder_no_phone`, `reminder_suppressed`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"`meta_error`: Meta rejected the call (with what it said).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/maintenance-reminders/{id}/cancel":{"post":{"summary":"Cancel a maintenance reminder that was not sent yet","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"MaintenanceReminder.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`reminder_not_pending`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/maintenance-reminders/{id}/handled":{"patch":{"summary":"Mark a reminder that asked for an appointment as contacted","description":"Body `{ done }` (false undoes it).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{}}},"responses":{"200":{"description":"MaintenanceReminder.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`reminder_not_booked`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/survey":{"get":{"summary":"The satisfaction survey of an appointment type","description":"Its draft (the default one if it was never set up), whether it is on, when it is sent, the Meta template of the opening message and the published versions.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveyConfig.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such appointment type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"summary":"Save the survey draft and its send rule","description":"Body: `{ enabled?, send?, draft? }`. `send`: `{mode: hours_after, hours}` or `{mode: days_after, days, time: HH:MM}`, counted from the moment the appointment is marked attended. Does not publish. `enabled: true` needs a published version.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"SurveyConfig.","content":{"application/json":{}}},"400":{"description":"Invalid draft or rule (`question_invalid`, `condition_invalid`, `send_invalid`...).","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"}}}},"422":{"description":"`survey_not_published`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Delete the survey of a type (logical)","description":"The config moves to the type's `survey_history` and the type has no survey again (the next one starts from the default). Versions are marked deleted (not listed; late answers still read), responses are kept and scheduled sends that did not go out are skipped. Returns `{ config, usage }`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"`{ config, usage }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`survey_not_found`: the type never had one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/survey/usage":{"get":{"summary":"What deleting the survey affects","description":"`{ scheduled, inProgress, responses }`: scheduled sends that would not go out, sent ones the customer can still answer, and stored responses (kept).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveyUsage.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/survey/publish":{"post":{"summary":"Publish the survey draft as a new version","description":"Builds the WhatsApp Flow of the questions and publishes it in Meta, and submits the opening message template for approval when it changed. Answers point to the version they were given on.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveyConfig.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`flow_rejected` (with what Meta said) or `whatsapp_not_connected`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Meta rejected a call.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointment-types/{id}/survey/status":{"post":{"summary":"Refresh the survey template and Flow status from Meta","description":"Asks Meta again about the opening message template and the latest version's Flow.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveyConfig.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Meta rejected a call.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scheduling/appointments/{id}/survey":{"post":{"summary":"Send the survey of an attended appointment now","description":"For an appointment marked attended whose survey did not go out.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"Appointment.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`survey_not_attended` or `survey_already_sent`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`survey_not_published`, `template_not_approved`, `whatsapp_not_connected`, `survey_no_phone`, `survey_suppressed`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Meta rejected the message.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/summary":{"get":{"summary":"Survey results summary","description":"Query: `appointmentTypeId`, `version`, `from`, `to` (ISO 8601). The funnel counts by send date; answers, by answer date. NPS from the recommendation question (1-10: promoters 9-10, passives 7-8, detractors 1-6), per-question stats, weekly trend and, without a type, one row per type.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveySummary.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/responses":{"get":{"summary":"Survey responses","description":"Query: the summary filters plus `followUp` (`open` | `done`), `q`, `limit`, `offset`. Newest first.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"`{ items: SurveyResponse[], total }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/responses/{id}":{"get":{"summary":"One survey response","description":"Every answer, the appointment and the conversation it came from.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveyResponse.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/responses/{id}/follow-up":{"patch":{"summary":"Mark a follow-up as done (or reopen it)","description":"Body: `{ done: boolean, note? }`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"SurveyResponse.","content":{"application/json":{}}},"400":{"description":"`follow_up_none`: nothing to follow up.","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/surveys/export":{"get":{"summary":"Export survey responses as CSV","description":"The same filters as the responses list; one column per question.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"text/csv.","content":{"text/csv":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/standalone":{"get":{"summary":"Standalone surveys","description":"Surveys that do not depend on an appointment (a 0 km delivery, a visit that did not buy): they go out to a send list. Newest first, each with its `config` (same shape as an appointment type's SurveyConfig; `appointmentTypeId` is `survey:<id>`, the key its versions and responses use) and how many sends it has in each status.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"StandaloneSurvey[].","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a standalone survey","description":"Body: `{ name }`. Starts with the default draft, unpublished.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"201":{"description":"StandaloneSurvey.","content":{"application/json":{}}},"400":{"description":"`survey_name_invalid` or `survey_name_taken`.","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/surveys/standalone/{id}":{"get":{"summary":"One standalone survey","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"StandaloneSurvey.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`standalone_survey_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"summary":"Rename it and/or save its draft","description":"Body: `{ name?, draft? }`. Does not publish. A standalone draft may declare its own variables (`draft.variables: [{ name, label, example }]`, used in `intro` as `{{name}}` next to `{{fullname}}`) and, like any survey, multiple-choice questions (`kind: choice`, 2–10 `options: [{ id, label }]`, label up to 30 characters).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"StandaloneSurvey.","content":{"application/json":{}}},"400":{"description":"Invalid draft (`question_invalid`, `options_invalid`, `variables_invalid`, `variable_unknown`...).","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"}}}}}},"delete":{"summary":"Delete a standalone survey (logical)","description":"It is no longer listed and pending sends are skipped; versions and responses are kept. Returns `{ skipped }`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"`{ skipped }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/standalone/{id}/publish":{"post":{"summary":"Publish the standalone survey draft as a new version","description":"Builds and publishes the WhatsApp Flow; submits the opening message template if it changed (examples for its own variables come from `variables[].example`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"StandaloneSurvey.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`flow_rejected` or `whatsapp_not_connected`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/standalone/{id}/status":{"post":{"summary":"Refresh its template and Flow status from Meta","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"StandaloneSurvey.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/standalone/{id}/sends":{"get":{"summary":"The sends of a standalone survey","description":"Query: `status` (pending, sent, started, declined, completed, skipped, failed), `limit`, `offset`. Newest first: `{ items: SurveySend[], total }`.","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"`{ items, total }`.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Load a send list","description":"Body: `{ rows: [{ fullname, phone, variables }], dryRun?, sendAt?, batchId?, offset? }`. Each row needs a name, a phone and every own variable the opening message uses. Someone with an unanswered send of this survey is a `duplicate`; someone who opted out of WhatsApp is `invalid`; non-customers are created. With `dryRun: true` nothing is loaded. Without `sendAt` they go out on the next sweep. Up to 2000 rows to validate, 500 to load per request (send the next chunks with the returned `batchId`).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"SurveySendResponse: `{ batchId, dryRun, results, counts }`.","content":{"application/json":{}}},"400":{"description":"`rows_required`, `rows_too_many` or `date_invalid`.","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"}}}},"422":{"description":"`survey_not_published`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/surveys/standalone/{id}/sends/{sendId}/send":{"post":{"summary":"Send one send now","description":"For a send that did not go out (or failed).","security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"responses":{"200":{"description":"SurveySend.","content":{"application/json":{}}},"401":{"description":"Missing, unknown, disabled or wrong-tenant API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`survey_already_sent`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`survey_not_published`, `template_not_approved`, `whatsapp_not_connected`, `survey_no_phone`, `survey_suppressed`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/maintenance/sweep":{"post":{"summary":"Sweep maintenance reminders (all tenants)","description":"Called by the flow engine every few minutes: sends the reminders whose time came, for types with the reminder on and its template approved.","security":[{"opsSecret":[]}],"responses":{"200":{"description":"`{ tenants: { <tenant>: { sent, skipped, failed } } }`.","content":{"application/json":{}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/maintenance/reply":{"post":{"summary":"A customer tapped a maintenance reminder button","description":"Body `{ tenant, payload: 'mt:<id>:book|decline', text?, wamid?, contextId?, from?, at? }`. `book` hands the conversation to an advisor (`metadata.active_agent = operator`, `handedOff: true`); the engine's operator sweep notifies the operators.","security":[{"opsSecret":[]}],"responses":{"200":{"description":"`{ handled, action, reminderId, sessionId, handedOff, duplicate?, closed? }`.","content":{"application/json":{}}},"400":{"description":"`tenant_required` or missing payload.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/attachments/whatsapp":{"post":{"summary":"Store a file a customer sent over WhatsApp Cloud (flow engine)","description":"Body `{ tenant, sessionId, mediaId, mimeType?, name? }`. The API downloads the media from Meta with the tenant's business token (it never leaves the API) and stores it with the conversation, like Twilio's `/api/ops/attachments`. The engine then appends the inbound message with this attachment.","security":[{"opsSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"201":{"description":"`{ id, kind, name, mimeType, size }`.","content":{"application/json":{}}},"400":{"description":"`tenant_required`, `session_required`, `media_required` or `media_too_large` (over 20 MB).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown conversation, `no_cloud_connection` or `media_not_found` (Meta keeps media for a limited time).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/notifications/me":{"get":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Where and about what the system notifies the caller","description":"Per person (a Panel user, not a machine key: `403 person_required`). `{ target: { devices, events, unseenAfterMinutes }, line: { provider: 'meta'|'twilio'|'none', number }, template: { name, status, rejectionReason, submittedAt }, emailAvailable }`. WhatsApp notices go out from the tenant's own number (`line`).","responses":{"200":{"description":"The caller's notification settings.","content":{"application/json":{}}},"403":{"description":"`person_required`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Save where and about what the system notifies the caller","description":"Body `{ devices: [{ provider: 'whatsapp'|'email', identifier, enabled }], events, unseenAfterMinutes }`. Same `notification_targets` the consoles use; the flow engine reads it.","requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"The saved settings.","content":{"application/json":{}}},"400":{"description":"`provider_invalid`, `whatsapp_invalid` or an invalid email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/notifications/template":{"get":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"The tenant's team-notice template","description":"`{ body, name, status, rejectionReason, submittedAt }`. Nobody sets it up: with a connected number it is submitted on its own with the preset text, and while Meta reviews it the status is checked again.","responses":{"200":{"description":"The template state.","content":{"application/json":{}}}}},"post":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Submit the tenant's team-notice template to Meta","description":"The UTILITY template that lets WhatsApp notices reach a teammate outside the 24 h window. Replaces the previous one.","responses":{"200":{"description":"`{ name, status, rejectionReason, submittedAt }`.","content":{"application/json":{}}},"422":{"description":"`whatsapp_not_connected`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"`meta_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/notifications/template/status":{"post":{"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"summary":"Ask Meta for the team-notice template status","responses":{"200":{"description":"`{ name, status, rejectionReason, submittedAt }`.","content":{"application/json":{}}},"400":{"description":"`template_missing`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/assistant/appointments":{"post":{"summary":"One step of the WhatsApp assistant over the customer's appointments (flow engine)","description":"Body `{ tenant, from, text, mode?: 'list'|'manage', state? }`. The customer is recognized by their WhatsApp number (profile identity or the last 10 digits of the phone). `list` answers with their pending appointments and ends; `manage` lets them pick one and cancel it (with confirmation) or modify it (marked `reschedule_requested`, `handoff: { reason }` hands the conversation to an advisor).","security":[{"opsSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"`{ reply, state, handoff }` — `state: null` means the step ended.","content":{"application/json":{}}},"400":{"description":"`tenant_required` or missing `from`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/advisor/handoff-note":{"get":{"summary":"What to add to the handoff message (flow engine)","description":"Query `tenant`. Outside the advisors' hours: when they open (`Nuestros asesores atienden … te van a responder el lunes a las 9.`); inside: how long they usually take, when there is an estimate. `{ note: string | null }`.","security":[{"opsSecret":[]}],"responses":{"200":{"description":"`{ note }`.","content":{"application/json":{}}},"400":{"description":"`tenant_required`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/advisor/reply":{"post":{"summary":"An advisor replied from the WhatsApp Business app (flow engine)","description":"Body `{ tenant, sessionId, at? }`. Measures the customer's wait like a reply from the Panel (first reply of the handoff episode, business minutes only).","security":[{"opsSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"`{ recorded }`.","content":{"application/json":{}}},"400":{"description":"`tenant_required` or missing `sessionId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/whatsapp/send":{"post":{"summary":"Send through the tenant's WhatsApp Cloud number (flow engine)","description":"Body `{ tenant, to, text }` or `{ tenant, to, template: { name, language?, parameters? } }`. The tenant's business token never leaves the API. Only the tenant's connected number is used: with no Cloud connection the send fails (`no_cloud_connection`), it never falls back to another provider or number. Meta rejections come back as `200` with `ok: false` and Meta's code, so the engine records them.","security":[{"opsSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"`{ ok, id, status: 'sent'|'failed', error_code, error_message, from }`.","content":{"application/json":{}}},"400":{"description":"`tenant_required`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/surveys/sweep":{"post":{"summary":"Send the surveys that are due, for every tenant (flow engine)","description":"Called by the flow engine every few minutes, once for all tenants.","security":[{"opsSecret":[]}],"responses":{"200":{"description":"`{ tenants: { <tenant>: { sent, skipped, failed } | { error } } }`.","content":{"application/json":{}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ops/surveys/event":{"post":{"summary":"A survey event from WhatsApp (flow engine)","description":"Body: `{ tenant, payload?, text?, responseJson?, wamid?, contextId?, from?, at? }`: a tap on the opening message (`payload` `sv:<appointment>:start|decline`) or the Flow answer (`nfm_reply.response_json`), with the tenant of the number that received it.","security":[{"opsSecret":[]}],"requestBody":{"required":true,"content":{"application/json":{}}},"responses":{"200":{"description":"`{ handled, kind, appointmentId, sessionId, duplicate? }`.","content":{"application/json":{}}},"400":{"description":"No `payload` or `responseJson`, or no valid `tenant`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or wrong `x-ops-secret`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}